diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 5a567049e0..d0853de0bc 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -9,6 +9,14 @@ plugins { android { namespace = "com.tangem.features.domain.swap" + + testOptions { + unitTests.isIncludeAndroidResources = false + } +} + +tasks.withType().configureEach { + useJUnitPlatform() } dependencies { @@ -62,4 +70,8 @@ dependencies { implementation(tangemDeps.card.core) implementation(deps.moshi) ksp(deps.moshi.kotlin.codegen) + + /** Test */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt new file mode 100644 index 0000000000..b0241f7f9e --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindBestQuoteTest.kt @@ -0,0 +1,913 @@ +package com.tangem.feature.swap.domain + +import android.util.Base64 +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.solana.SolanaTransactionHelper +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionExtras +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.FeePaidCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.ui.SwapState +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Tests for [SwapInteractorImpl.findBestQuote] — the core quote-dispatch method. + * + * Covers: + * - Empty / unparseable amount handling + * - DEX provider path on EVM networks (balance enough, allowance enough) + * - DEX provider repository error handling (returns SwapError) + * - DEX_BRIDGE provider sharing the DEX dispatch branch + * - Solana DEX path routing via the Solana-specific branch + * - CEX provider dispatch including null txFee edge case + * - yieldSupplyStatus.isActive returning [ExpressDataError.DexActiveSupplyError] + * - Mixed (DEX + CEX) provider list — each routed to its own path + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplFindBestQuoteTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val solanaNetwork = Blockchain.Solana.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @BeforeEach + fun setup() { + // Common stubs that most tests rely on. Individual tests can override. + coEvery { currenciesRepository.getFeePaidCurrency(any(), any()) } returns FeePaidCurrency.Coin + coEvery { walletManagersFacade.getNativeTokenBalance(any(), any(), any()) } returns BigDecimal("10") + coEvery { + getCurrencyCheckUseCase.invoke( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any(), + ) + } returns buildCryptoCurrencyCheck() + coEvery { + validateTransactionUseCase.invoke( + amount = any(), + fee = any(), + memo = any(), + destination = any(), + userWalletId = any(), + network = any(), + ) + } returns Unit.right() + coEvery { quotesRepository.getMultiQuoteSyncOrNull(any()) } answers { + firstArg>().map { rawId -> + QuoteStatus( + rawCurrencyId = rawId, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = BigDecimal.ONE, + fiatRateUSD = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + ), + ) + }.toSet() + } + coEvery { multiQuoteStatusFetcher.invoke(any()) } returns Unit.right() + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase.invoke(any(), any()) } returns null.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns buildCoinCurrency() + every { allowPermissionsHandler.isAddressAllowanceInProgress(any()) } returns false + coEvery { + getAllowanceInfoUseCase.invoke( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns (AllowanceInfo.Enough(allowance = BigDecimal("1000")) as AllowanceInfo).right() + every { createTransactionExtrasUseCase.invoke(data = any(), network = any()) } returns + mockk(relaxed = true).right() + coEvery { + getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any()) + } returns mockk(relaxed = true).right() + } + + @Nested + inner class EmptyAmountHandling { + + @Test + fun `should return EmptyAmountState for all providers when amount is zero`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider, cexProvider), + amountToSwap = "0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(2) + assertThat(result[dexProvider]).isInstanceOf(SwapState.EmptyAmountState::class.java) + assertThat(result[cexProvider]).isInstanceOf(SwapState.EmptyAmountState::class.java) + } + + @Test + fun `should return EmptyAmountState for all providers when amount is unparseable`() = runTest { + // Given + val provider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(provider), + amountToSwap = "not-a-number", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[provider]).isInstanceOf(SwapState.EmptyAmountState::class.java) + } + + @Test + fun `should return empty map when providers list is empty`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = emptyList(), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).isEmpty() + } + } + + @Nested + inner class DexProviderPath { + + @Test + fun `should return SwapState for DEX provider when repository findBestQuote succeeds and balance enough`() = + runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5")) + val swapData = buildSwapDataModelDex() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — has a result entry for the DEX provider; type of state is decided by internal logic + assertThat(result).hasSize(1) + assertThat(result.containsKey(dexProvider)).isTrue() + assertThat(result[dexProvider]).isNotNull() + } + + @Test + fun `should return SwapError with DexActiveSupplyError when yieldSupply is active`() = runTest { + // Given — yieldSupplyActive=true short-circuits to DexActiveSupplyError + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + yieldSupplyActive = true, + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = (state ?: error("state must not be null")) as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.DexActiveSupplyError) + } + + @Test + fun `should set isBalanceEnough to false when from-token balance is less than swap amount`() = runTest { + // Given — balance is 0.01, swap amount is 1.0 → insufficient + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + amount = BigDecimal("0.01"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel(toAmount = BigDecimal("0.5")) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.QuotesLoadedState::class.java) + val loaded = state as SwapState.QuotesLoadedState + assertThat(loaded.preparedSwapConfigState.isBalanceEnough).isFalse() + } + + @Test + fun `should return non-null state for DEX provider when repository findBestQuote returns error`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns ExpressDataError.UnknownError.left() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — a SwapState is emitted for the provider (not an EmptyAmountState) + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isNotNull() + assertThat(state).isNotInstanceOf(SwapState.EmptyAmountState::class.java) + } + } + + @Nested + inner class DexBridgeProviderPath { + + @Test + fun `should return entry keyed by the DEX_BRIDGE provider type`() = runTest { + // Given — DEX_BRIDGE shares the same DEX branch as DEX + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexBridgeProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexBridgeProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexBridgeProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result.keys.first().type).isEqualTo(ExchangeProviderType.DEX_BRIDGE) + } + } + + @Nested + inner class SolanaDexPath { + + @Test + fun `should produce result entry when network is Solana and quote is successful`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(0) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = solanaNetwork, + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + val solanaSwapData = buildSwapDataModelDex() + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns solanaSwapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result.containsKey(dexProvider)).isTrue() + } + + @Test + fun `should return SwapError TooLargeSolanaTransactionError when tx bytes exceed threshold on Cold wallet`() = + runTest { + // Given — decode returns an oversized array; mock the Solana helper to preserve its size + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(931) + io.mockk.mockkObject(SolanaTransactionHelper) + every { + SolanaTransactionHelper.removeSignaturesPlaceholders(any()) + } returns ByteArray(931) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val coldWallet = mockk(relaxed = true) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("10"), + ).let { status -> + // replace the relaxed UserWallet mock with a real Cold mock so `is UserWallet.Cold` is true + SwapCurrencyStatus( + userWallet = coldWallet, + status = status.status, + account = status.account, + ) + } + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + val solanaSwapData = buildSwapDataModelDex(txData = "oversized==") + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = solanaNetwork, + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns solanaSwapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — oversized Solana tx on Cold wallet produces SwapError with TooLargeSolanaTransactionError + assertThat(result).hasSize(1) + val state = result[dexProvider] + assertThat(state).isInstanceOf(SwapState.SwapError::class.java) + val swapError = state as SwapState.SwapError + assertThat(swapError.error).isEqualTo(ExpressDataError.TooLargeSolanaTransactionError) + } + + @Test + fun `should produce non-empty state via Solana path when balance insufficient`() = runTest { + // Given — Solana path with Right quote but insufficient balance → getQuotesState branch + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = solanaNetwork, + isCoin = true, + amount = BigDecimal("0.000001"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = solanaNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = solanaNetwork, + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = dexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider), + amountToSwap = "1000.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[dexProvider]).isNotNull() + } + } + + @Nested + inner class CexProviderPath { + + @Test + fun `should produce result entry for CEX provider`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result.containsKey(cexProvider)).isTrue() + assertThat(result[cexProvider]).isNotNull() + } + + @Test + fun `should produce result entry for CEX provider with minimal fee state`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("5"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = cexProvider.providerId, + rateType = any(), + ) + } returns quoteModel.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then + assertThat(result).hasSize(1) + assertThat(result[cexProvider]).isNotNull() + } + } + + @Nested + inner class MixedProviderDispatch { + + @Test + fun `should dispatch each provider to its branch and return one entry per provider`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-1") + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, "cex-1") + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = "dex-1", + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = "cex-1", + rateType = any(), + ) + } returns quoteModel.right() + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = "dex-1", + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider, cexProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — both providers have an entry + assertThat(result).hasSize(2) + assertThat(result.containsKey(dexProvider)).isTrue() + assertThat(result.containsKey(cexProvider)).isTrue() + } + + @Test + fun `should return one entry per provider for DEX plus CEX plus DEX_BRIDGE on non-Solana network`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX, "dex-mix") + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, "cex-mix") + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE, "dex-bridge-mix") + val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + isCoin = true, + amount = BigDecimal("10"), + ) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val quoteModel = buildQuoteModel() + val swapData = buildSwapDataModelDex() + + listOf("dex-mix", "cex-mix", "dex-bridge-mix").forEach { pid -> + coEvery { + repository.findBestQuote( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = pid, + rateType = any(), + ) + } returns quoteModel.right() + } + + listOf("dex-mix", "dex-bridge-mix").forEach { pid -> + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = pid, + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns swapData.right() + } + + // When + val result = sut.findBestQuote( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + providers = listOf(dexProvider, cexProvider, dexBridgeProvider), + amountToSwap = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + txFeeSealedState = buildTxFeeSealedState(), + ) + + // Then — all three providers are dispatched and each has an entry + assertThat(result).hasSize(3) + assertThat(result.containsKey(dexProvider)).isTrue() + assertThat(result.containsKey(cexProvider)).isTrue() + assertThat(result.containsKey(dexBridgeProvider)).isTrue() + assertThat(result[dexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + assertThat(result[dexBridgeProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + assertThat(result[cexProvider]).isInstanceOf(SwapState.QuotesLoadedState::class.java) + } + } +} + +// region — test-local helpers + +private fun buildCryptoCurrencyCheck(): CryptoCurrencyCheck = CryptoCurrencyCheck( + dustValue = null, + reserveAmount = null, + minimumSendAmount = null, + existentialDeposit = null, + utxoAmountLimit = null, + isAccountFunded = true, + rentWarning = null, + isMemoRequired = false, +) + +private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), +): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-123", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = txData, + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), +) + +// endregion \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt new file mode 100644 index 0000000000..1f7e5c8d85 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplFindProvidersForPairTest.kt @@ -0,0 +1,160 @@ +package com.tangem.feature.swap.domain + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import io.mockk.coEvery +import io.mockk.every +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [SwapInteractorImpl.findProvidersForPair] and [SwapInteractorImpl.findProvidersForPairWithCheck]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplFindProvidersForPairTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + @Nested + inner class FindProvidersForPair { + + @Test + fun `should return providers of the first pair whose to-contractAddress equals destination contract`() { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val expectedProvider = buildSwapProvider(ExchangeProviderType.DEX, "expected") + val matchingPair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = listOf(expectedProvider), + ) + + // When + val result = sut.findProvidersForPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(matchingPair), + ) + + // Then + assertThat(result).containsExactly(expectedProvider) + } + + @Test + fun `should return empty list when pairs list is empty`() { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.findProvidersForPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = emptyList(), + ) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `should return empty list when no pair's to-contractAddress equals destination contract`() { + // Given — destination is a token with contractAddress "0xAbc", but pair's to-contract is "0" + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus( + networkRawId = btcNetwork, + contractAddress = "0xAbc", + isCoin = false, + ) + val unrelatedPair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = listOf(buildSwapProvider(ExchangeProviderType.CEX, "unrelated")), + ) + + // When + val result = sut.findProvidersForPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(unrelatedPair), + ) + + // Then + assertThat(result).isEmpty() + } + } + + @Nested + inner class FindProvidersForPairWithCheck { + + @Test + fun `should return empty list when rampStateManager checkAssetRequirements returns false`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + ) + + coEvery { + getAssetRequirementsUseCase.invoke(any(), any()) + } returns null.right() + every { rampStateManager.checkAssetRequirements(any()) } returns false + + // When + val result = sut.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(pair), + ) + + // Then + assertThat(result).isEmpty() + } + + @Test + fun `should return providers from matching pair when checkAssetRequirements returns true`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, contractAddress = "0", isCoin = true) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork, contractAddress = "0", isCoin = true) + val providerA = buildSwapProvider(ExchangeProviderType.DEX, "A") + val providerB = buildSwapProvider(ExchangeProviderType.CEX, "B") + val pair = buildSwapPairLeast( + fromNetwork = ethNetwork, + fromContract = "0", + toNetwork = btcNetwork, + toContract = "0", + providers = listOf(providerA, providerB), + ) + + coEvery { + getAssetRequirementsUseCase.invoke(any(), any()) + } returns null.right() + every { rampStateManager.checkAssetRequirements(any()) } returns true + + // When + val result = sut.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + pairs = listOf(pair), + ) + + // Then + assertThat(result).containsExactly(providerA, providerB) + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt new file mode 100644 index 0000000000..b624a079ab --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetNativeTokenTest.kt @@ -0,0 +1,86 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +/** + * Tests for [SwapInteractorImpl.getNativeToken]. + * + * Behavior: + * - Look up cached portfolio coins for the user wallet via [MultiWalletCryptoCurrenciesSupplier]. + * - Return the coin matching the target network (by `id` and `derivationPath`). + * - If supplier returns null or no match → fall back to [CurrenciesRepository.createCoinCurrency]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplGetNativeTokenTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + + @Test + fun `should return a Coin from the supplier whose network matches the target`() = runTest { + // Given — a single matching coin in the supplier + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val targetNetwork = fromStatus.currency.network + + val matchingCoin = mockk(relaxed = true) { + every { network } returns targetNetwork + } + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(matchingCoin) + + // When + val result = sut.getNativeToken(fromStatus) + + // Then + assertThat(result).isSameInstanceAs(matchingCoin) + } + + @Test + fun `should fall back to createCoinCurrency when supplier returns null`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val createdCoin = buildCoinCurrency(networkRawId = ethNetwork) + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns null + coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin + + // When + val result = sut.getNativeToken(fromStatus) + + // Then + assertThat(result).isSameInstanceAs(createdCoin) + coVerify(exactly = 1) { currenciesRepository.createCoinCurrency(any()) } + } + + @Test + fun `should fall back to createCoinCurrency when no matching coin is in the supplier's list`() = runTest { + // Given — all returned coins are for a different network + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = true) + val unrelatedCoin = mockk(relaxed = true) { + every { network } returns mockk(relaxed = true) { + every { id } returns mockk(relaxed = true) + every { derivationPath } returns Network.DerivationPath.None + } + } + val createdCoin = buildCoinCurrency(networkRawId = ethNetwork) + + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(unrelatedCoin) + coEvery { currenciesRepository.createCoinCurrency(any()) } returns createdCoin + + // When + val result = sut.getNativeToken(fromStatus) + + // Then + assertThat(result).isSameInstanceAs(createdCoin) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetPairTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetPairTest.kt new file mode 100644 index 0000000000..fc6ad354f3 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetPairTest.kt @@ -0,0 +1,201 @@ +package com.tangem.feature.swap.domain + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.swap.models.SwapTxType +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import io.mockk.coEvery +import io.mockk.coVerify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplGetPairTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + private val fromStatus = buildSwapCurrencyStatus( + networkRawId = ethNetwork, + contractAddress = "0", + isCoin = true, + ) + private val toStatus = buildSwapCurrencyStatus( + networkRawId = btcNetwork, + contractAddress = "0", + isCoin = true, + ) + + @Nested + inner class `getPair happy path` { + + @Test + fun `should return Right with mapped SwapPairLeast list when use case succeeds`() = runTest { + // Given + val expressProvider = buildExpressProvider(providerId = "p1", type = ExpressProviderType.DEX) + val pairModel = buildSwapPairModel( + fromNetworkRawId = ethNetwork, + fromContractAddress = "0", + toNetworkRawId = btcNetwork, + toContractAddress = "0", + providers = listOf(expressProvider), + ) + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = fromStatus, + secondarySwapCurrencyStatus = toStatus, + filterProviderTypes = any(), + swapTxType = SwapTxType.Swap, + ) + } returns listOf(pairModel).right() + + // When + val result = sut.getPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + filterProviderTypes = listOf(ExchangeProviderType.DEX), + ) + + // Then + assertThat(result.isRight()).isTrue() + result.onRight { pairs -> + assertThat(pairs).hasSize(1) + val pair = pairs.first() + assertThat(pair.from.network).isEqualTo(ethNetwork) + assertThat(pair.from.contractAddress).isEqualTo("0") + assertThat(pair.to.network).isEqualTo(btcNetwork) + assertThat(pair.providers).hasSize(1) + assertThat(pair.providers.first().providerId).isEqualTo("p1") + } + } + + @Test + fun `should map coin contractAddress to 0 in LeastTokenInfo`() = runTest { + // Given — coin currency (contractAddress = "0" by convention) + val pairModel = buildSwapPairModel( + fromNetworkRawId = ethNetwork, + fromContractAddress = "0", + toNetworkRawId = btcNetwork, + toContractAddress = "0", + ) + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = any(), + swapTxType = any(), + ) + } returns listOf(pairModel).right() + + // When + val result = sut.getPair(fromStatus, toStatus, emptyList()) + + // Then + assertThat(result.isRight()).isTrue() + result.onRight { pairs -> + assertThat(pairs.first().from.contractAddress).isEqualTo("0") + assertThat(pairs.first().to.contractAddress).isEqualTo("0") + } + } + + @Test + fun `should map all ExchangeProviderType variants to ExpressProviderType correctly`() = runTest { + // Given + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = listOf( + ExpressProviderType.DEX, + ExpressProviderType.CEX, + ExpressProviderType.DEX_BRIDGE, + ), + swapTxType = SwapTxType.Swap, + ) + } returns emptyList().right() + + // When + val result = sut.getPair( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + filterProviderTypes = listOf( + ExchangeProviderType.DEX, + ExchangeProviderType.CEX, + ExchangeProviderType.DEX_BRIDGE, + ), + ) + + // Then + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = listOf( + ExpressProviderType.DEX, + ExpressProviderType.CEX, + ExpressProviderType.DEX_BRIDGE, + ), + swapTxType = SwapTxType.Swap, + ) + } + } + + @Test + fun `should return empty list when use case returns empty pairs`() = runTest { + // Given + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = any(), + swapTxType = any(), + ) + } returns emptyList().right() + + // When + val result = sut.getPair(fromStatus, toStatus, emptyList()) + + // Then + assertThat(result.isRight()).isTrue() + result.onRight { pairs -> + assertThat(pairs).isEmpty() + } + } + } + + @Nested + inner class `getPair error path` { + + @Test + fun `should return Left with ExpressError when use case returns Left`() = runTest { + // Given + val expectedError = ExpressError.DataError(code = 400, description = "bad request") + coEvery { + getSwapPairUseCase.invoke( + primarySwapCurrencyStatus = any(), + secondarySwapCurrencyStatus = any(), + filterProviderTypes = any(), + swapTxType = any(), + ) + } returns expectedError.left() + + // When + val result = sut.getPair(fromStatus, toStatus, emptyList()) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(ExpressError.DataError::class.java) + assertThat((error as ExpressError.DataError).code).isEqualTo(400) + } + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetTokenBalanceTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetTokenBalanceTest.kt new file mode 100644 index 0000000000..fde79fbcf5 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplGetTokenBalanceTest.kt @@ -0,0 +1,76 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** + * Tests for [SwapInteractorImpl.getTokenBalance]. + * + * Trivial conversion: `SwapAmount(value.amount ?: ZERO, currency.decimals)`. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplGetTokenBalanceTest : SwapInteractorImplTestBase() { + + @Test + fun `should return SwapAmount with the reported balance and decimals when value amount is non-null`() { + // Given + val currency = mockk(relaxed = true) { + every { decimals } returns 18 + } + val value = mockk(relaxed = true) { + every { amount } returns BigDecimal("5.75") + } + val status = CryptoCurrencyStatus(currency = currency, value = value) + + // When + val result = sut.getTokenBalance(status) + + // Then + assertThat(result.value).isEqualTo(BigDecimal("5.75")) + assertThat(result.decimals).isEqualTo(18) + } + + @Test + fun `should return SwapAmount with ZERO when value amount is null`() { + // Given — a non-Loaded value with null amount (e.g. Loading state) + val currency = mockk(relaxed = true) { + every { decimals } returns 8 + } + val value = mockk(relaxed = true) { + every { amount } returns null + } + val status = CryptoCurrencyStatus(currency = currency, value = value) + + // When + val result = sut.getTokenBalance(status) + + // Then + assertThat(result.value).isEqualTo(BigDecimal.ZERO) + assertThat(result.decimals).isEqualTo(8) + } + + @Test + fun `should preserve decimals from the underlying currency`() { + // Given — Token with custom decimals + val currency = mockk(relaxed = true) { + every { decimals } returns 6 + } + val value = mockk(relaxed = true) { + every { amount } returns BigDecimal("100") + } + val status = CryptoCurrencyStatus(currency = currency, value = value) + + // When + val result = sut.getTokenBalance(status) + + // Then + assertThat(result.decimals).isEqualTo(6) + assertThat(result.value).isEqualTo(BigDecimal("100")) + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt new file mode 100644 index 0000000000..d1f5800196 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplLoadFeeTest.kt @@ -0,0 +1,441 @@ +package com.tangem.feature.swap.domain + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +/** + * Tests for [SwapInteractorImpl.loadFeeForSwapTransaction] (both overloads). + * + * Overload 1 (returns [Either]): + * - DEX / DEX_BRIDGE → always GaslessError.NetworkIsNotSupported + * - CEX + zero or unparseable amount → UnknownError + * - CEX + selectedFeeToken != null → delegates to [estimateFeeForTokenUseCase] + * - CEX + selectedFeeToken == null → delegates to [estimateFeeForGaslessTxUseCase] + * + * Overload 2 (returns [Either]): + * - DEX / DEX_BRIDGE + zero amount → UnknownError + * - DEX / DEX_BRIDGE + getExchangeData error → UnknownError + * - CEX + zero amount → UnknownError + * - CEX + non-zero amount → delegates to [estimateFeeUseCase] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplLoadFeeTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val btcNetwork = Blockchain.Bitcoin.toNetworkId() + + // ------------------------------------------------------------------------- + // Overload 1 + // ------------------------------------------------------------------------- + + @Nested + inner class `overload 1 — CEX and token fee paths` { + + @Test + fun `should return Left GaslessError for DEX provider`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java) + } + } + + @Test + fun `should return Left GaslessError for DEX_BRIDGE provider`() = runTest { + // Given + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexBridgeProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.GaslessError.NetworkIsNotSupported::class.java) + } + } + + @Test + fun `should return Left UnknownError for CEX provider when amount is zero`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should return Left UnknownError for CEX provider when amount is invalid string`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "not-a-decimal", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should delegate to estimateFeeForTokenUseCase when CEX provider has non-null selectedFeeToken`() = + runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val feeTokenStatus = mockk(relaxed = true) + val expectedFeeExtended = mockk(relaxed = true) + + coEvery { + estimateFeeForTokenUseCase.invoke( + userWallet = any(), + feeTokenCurrencyStatus = feeTokenStatus, + sendingTokenCurrencyStatus = any(), + amount = any(), + ) + } returns expectedFeeExtended.right() + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "1.5", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = feeTokenStatus, + ) + + // Then + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + estimateFeeForTokenUseCase.invoke( + userWallet = any(), + feeTokenCurrencyStatus = feeTokenStatus, + sendingTokenCurrencyStatus = any(), + amount = BigDecimal("1.5"), + ) + } + } + + @Test + fun `should pass positive non-NaN amount to estimateFeeForGaslessTxUseCase for CEX with tiny nonzero amount and null selectedFeeToken`() = + runTest { + // Given — tiny but nonzero amount; null selectedFeeToken routes to estimateFeeForGaslessTxUseCase + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val feeExtended = mockk(relaxed = true) + val capturedAmount = slot() + + coEvery { + estimateFeeForGaslessTxUseCase.invoke( + amount = capture(capturedAmount), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } returns feeExtended.right() + + // When + sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "0.000001", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then — captured amount is positive, finite, non-NaN + assertThat(capturedAmount.captured).isNotNull() + assertThat(capturedAmount.captured.signum()).isGreaterThan(0) + assertThat(capturedAmount.captured.toDouble().isNaN()).isFalse() + assertThat(capturedAmount.captured.toDouble().isInfinite()).isFalse() + // verify estimateFeeForGaslessTxUseCase was called with the exact parsed amount + coVerify(exactly = 1) { + estimateFeeForGaslessTxUseCase.invoke( + amount = BigDecimal("0.000001"), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } + } + + @Test + fun `should delegate to estimateFeeForGaslessTxUseCase when CEX provider has null selectedFeeToken`() = + runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val expectedFeeExtended = mockk(relaxed = true) + + coEvery { + estimateFeeForGaslessTxUseCase.invoke( + amount = any(), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } returns expectedFeeExtended.right() + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + amount = "2.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + selectedFeeToken = null, + ) + + // Then + assertThat(result.isRight()).isTrue() + coVerify(exactly = 1) { + estimateFeeForGaslessTxUseCase.invoke( + amount = BigDecimal("2.0"), + userWallet = any(), + sendingTokenCurrencyStatus = any(), + ) + } + } + } + + // ------------------------------------------------------------------------- + // Overload 2 + // ------------------------------------------------------------------------- + + @Nested + inner class `overload 2 — DEX and CEX TransactionFee paths` { + + @Test + fun `should return Left UnknownError for DEX when amount is zero`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should return Left UnknownError for DEX when getExchangeData returns error`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + coEvery { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } returns ExpressDataError.UnknownError.left() + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> + assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) + } + } + + @Test + fun `should not call getExchangeData and return UnknownError for DEX when amount is zero`() = runTest { + // Given — zero amount must short-circuit before hitting repository + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + result.onLeft { error -> assertThat(error).isInstanceOf(GetFeeError.UnknownError::class.java) } + coVerify(exactly = 0) { + repository.getExchangeData( + userWallet = any(), + fromContractAddress = any(), + fromNetwork = any(), + toContractAddress = any(), + fromAddress = any(), + toNetwork = any(), + fromAmount = any(), + fromDecimals = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + toAddress = any(), + expressOperationType = any(), + refundAddress = any(), + ) + } + } + + @Test + fun `should return Left UnknownError for DEX_BRIDGE when amount is zero`() = runTest { + // Given + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = dexBridgeProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `should return Left UnknownError for CEX when amount is zero`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + // When + val result = sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + ) + + // Then + assertThat(result.isLeft()).isTrue() + } + + @Test + fun `should delegate to estimateFeeUseCase for CEX provider with non-zero amount`() = runTest { + // Given — return Left to avoid the patchTransactionFeeForSwap branch which requires concrete Fee types + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX) + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork) + val toStatus = buildSwapCurrencyStatus(networkRawId = btcNetwork) + + coEvery { + estimateFeeUseCase.invoke( + amount = any(), + userWallet = any(), + cryptoCurrencyStatus = any(), + ) + } returns GetFeeError.UnknownError.left() + + // When + sut.loadFeeForSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = "1.0", + reduceBalanceBy = BigDecimal.ZERO, + provider = cexProvider, + ) + + // Then + coVerify(exactly = 1) { + estimateFeeUseCase.invoke( + amount = BigDecimal("1.0"), + userWallet = any(), + cryptoCurrencyStatus = any(), + ) + } + } + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt new file mode 100644 index 0000000000..131da6aa29 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplOnSwapTest.kt @@ -0,0 +1,1034 @@ +package com.tangem.feature.swap.domain + +import android.util.Base64 +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.ui.* +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(PER_CLASS) +internal class SwapInteractorImplOnSwapTest : SwapInteractorImplTestBase() { + + private val ethNetwork = Blockchain.Ethereum.toNetworkId() + private val solanaNetwork = Blockchain.Solana.toNetworkId() + + @BeforeEach + fun setupOnSwap() { + // Clear recorded calls so that coVerify(exactly = 1) counts only the current test's call. + clearMocks( + sendTransactionUseCase, + createTransactionUseCase, + createTransferTransactionUseCase, + createAndSendGaslessTransactionUseCase, + repository, + swapTransactionRepository, + answers = false, + ) + // isDemoCardUseCase should return false by default so the non-demo path is exercised. + // Individual tests that need demo mode override this. + every { isDemoCardUseCase(any()) } returns false + } + + // region — shared helpers + + /** + * Builds a SwapCurrencyStatus backed by an explicit UserWallet.Hot mock so that + * `userWallet is UserWallet.Cold` evaluates to false reliably. + */ + private fun buildHotSwapCurrencyStatus( + networkRawId: String = ethNetwork, + isCoin: Boolean = true, + ): SwapCurrencyStatus { + val hotWallet = mockk(relaxed = true) + return buildSwapCurrencyStatus(networkRawId = networkRawId, isCoin = isCoin).let { + SwapCurrencyStatus(userWallet = hotWallet, status = it.status, account = it.account) + } + } + + private fun buildCexSwapDataModel( + txTo: String = "0xCexAddress", + txId: String = "cex-tx-id", + txExtraId: String? = null, + externalTxUrl: String = "https://explorer.com/tx/123", + externalTxId: String = "ext-id-123", + toAmount: BigDecimal = BigDecimal("0.9"), + ): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.CEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = null, + txId = txId, + txTo = txTo, + txExtraId = txExtraId, + externalTxId = externalTxId, + externalTxUrl = externalTxUrl, + txExtraIdName = null, + ), + ) + + // endregion + + // ------------------------------------------------------------------------- + // Dispatcher Branches + // ------------------------------------------------------------------------- + + @Nested + inner class DispatcherBranches { + + @Test + fun `should return DemoMode for Cold card when isDemoCardUseCase returns true`() = runTest { + // Given + val coldWallet = mockk(relaxed = true) + every { isDemoCardUseCase(any()) } returns true + + val fromStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork).let { + SwapCurrencyStatus(userWallet = coldWallet, status = it.status, account = it.account) + } + val toStatus = buildHotSwapCurrencyStatus() + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val swapData = buildSwapDataModelDex() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.DemoMode::class.java) + coVerify(exactly = 0) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + coVerify(exactly = 0) { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + coVerify(exactly = 0) { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } + } + + @Test + fun `should route to onSwapCex and call getExchangeData for CEX provider`() = runTest { + // Given + val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-route-id") + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val cexSwapData = buildCexSwapDataModel() + + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = cexProvider.providerId, rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns cexSwapData.right() + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + coEvery { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xhash".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = cexProvider.providerId, rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } + } + + @Test + fun `should return UnknownError for DEX non-Solana when fee is null`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = null, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + coVerify(exactly = 0) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + } + + @Test + fun `should route to onSwapDex for DEX_BRIDGE non-Solana with valid fee`() = runTest { + // Given + val dexBridgeProvider = buildSwapProvider(ExchangeProviderType.DEX_BRIDGE) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + + every { + createTransactionExtrasUseCase.invoke( + data = any(), network = any(), gasLimit = any(), + ) + } returns mockk(relaxed = true).right() + + val txDataMock = mockk(relaxed = true) + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xhash-bridge".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexBridgeProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } + } + + @Test + fun `should route to onSwapSolanaDex for DEX Solana without calling createTransactionUseCase`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(100) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val swapData = buildSwapDataModelDex(txData = "dGVzdA==") + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xsolana-hash".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 0) { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + + unmockkStatic(Base64::class) + } + } + + // ------------------------------------------------------------------------- + // OnSwapDex + // ------------------------------------------------------------------------- + + @Nested + inner class OnSwapDex { + + @Test + fun `should return TxSent and call exchangeSent and storeTransaction on happy path`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + + every { + createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) + } returns mockk(relaxed = true).right() + + val txDataMock = mockk(relaxed = true) + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xdex-hash".right() + + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + val txSent = result as SwapTransactionState.TxSent + assertThat(txSent.txHash).isEqualTo("0xdex-hash") + + coVerify(exactly = 1) { + repository.exchangeSent( + userWallet = any(), txId = any(), fromNetwork = any(), + fromAddress = any(), payInAddress = any(), + txHash = "0xdex-hash", payInExtraId = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), toUserWalletId = any(), + fromCryptoCurrency = any(), toCryptoCurrency = any(), + fromAccount = any(), toAccount = any(), transaction = any(), + ) + } + } + + @Test + fun `should return UnknownError and not send when createTransactionUseCase fails`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + + every { + createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) + } returns mockk(relaxed = true).right() + + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns RuntimeException("create tx failed").left() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + coVerify(exactly = 0) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + } + + @Test + fun `should return TransactionError and not call exchangeSent when sendTransactionUseCase fails`() = runTest { + // Given + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus() + val toStatus = buildHotSwapCurrencyStatus() + val swapData = buildSwapDataModelDex(txValue = "1000000000000000") + val fee = buildTxFee() + val sendError = SendTransactionError.NetworkError(message = "timeout", code = "503") + + every { + createTransactionExtrasUseCase.invoke(data = any(), network = any(), gasLimit = any()) + } returns mockk(relaxed = true).right() + + val txDataMock = mockk(relaxed = true) + coEvery { + createTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + txExtras = any(), + ) + } returns txDataMock.right() + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns sendError.left() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) + val txError = result as SwapTransactionState.Error.TransactionError + assertThat(txError.error).isEqualTo(sendError) + + coVerify(exactly = 0) { + repository.exchangeSent(any(), any(), any(), any(), any(), any(), any()) + } + coVerify(exactly = 0) { + swapTransactionRepository.storeTransaction(any(), any(), any(), any(), any(), any(), any()) + } + } + } + + // ------------------------------------------------------------------------- + // OnSwapSolanaDex + // ------------------------------------------------------------------------- + + @Nested + inner class OnSwapSolanaDex { + + @AfterEach + fun tearDown() { + unmockkStatic(Base64::class) + } + + @Test + fun `should return TxSent and call exchangeSent and storeTransaction on happy path`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(100) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val swapData = buildSwapDataModelDex(txData = "dGVzdA==") + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xsolana-hash".right() + + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 SOL" + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = null, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + val txSent = result as SwapTransactionState.TxSent + assertThat(txSent.txHash).isEqualTo("0xsolana-hash") + + coVerify(exactly = 1) { + repository.exchangeSent( + userWallet = any(), txId = any(), fromNetwork = any(), + fromAddress = any(), payInAddress = any(), + txHash = "0xsolana-hash", payInExtraId = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), toUserWalletId = any(), + fromCryptoCurrency = any(), toCryptoCurrency = any(), + fromAccount = any(), toAccount = any(), transaction = any(), + ) + } + } + + @Test + fun `should return TransactionError when sendTransactionUseCase fails on Solana path`() = runTest { + // Given + mockkStatic(Base64::class) + every { Base64.decode(any(), any()) } returns ByteArray(100) + + val dexProvider = buildSwapProvider(ExchangeProviderType.DEX) + val fromStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val toStatus = buildHotSwapCurrencyStatus(networkRawId = solanaNetwork) + val swapData = buildSwapDataModelDex(txData = "dGVzdA==") + val sendError = SendTransactionError.UserCancelledError + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns sendError.left() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = dexProvider, + swapData = swapData, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = null, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) + val txError = result as SwapTransactionState.Error.TransactionError + assertThat(txError.error).isEqualTo(sendError) + } + } + + // ------------------------------------------------------------------------- + // OnSwapCex + // ------------------------------------------------------------------------- + + @Nested + inner class OnSwapCex { + + private val cexProvider = buildSwapProvider(ExchangeProviderType.CEX, providerId = "cex-id") + + // Both from and to use Hot wallets to avoid spurious is-Cold checks + private val fromStatus = buildHotSwapCurrencyStatus() + private val toStatus = buildHotSwapCurrencyStatus() + + private fun stubGetExchangeData(result: SwapDataModel) { + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns result.right() + } + + private fun stubCreateTransferTx(txDataMock: TransactionData.Uncompiled = mockk(relaxed = true)) { + coEvery { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } returns txDataMock.right() + } + + private suspend fun callOnSwap( + fee: TxFee? = buildTxFee(), + isTangemPayWithdrawal: Boolean = false, + ) = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = fee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = isTangemPayWithdrawal, + ) + + @Test + fun `should return ExpressError when getExchangeData fails`() = runTest { + // Given + val expressError = ExpressDataError.UnknownError + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns expressError.left() + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.ExpressError::class.java) + val error = result as SwapTransactionState.Error.ExpressError + assertThat(error.error).isEqualTo(expressError) + + coVerify(exactly = 0) { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } + } + + @Test + fun `should return UnknownError when getExchangeData returns DEX transaction type`() = runTest { + // Given — DEX-typed SwapDataModel where CEX path expects CEX type + val dexSwapData = buildSwapDataModelDex() + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns dexSwapData.right() + + // When + val result = callOnSwap() + + // Then — cast to CEX returns null → UnknownError + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should return TangemPayWithdrawalData without sending when isTangemPayWithdrawal is true`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel(txTo = "0xCexDepositAddress") + stubGetExchangeData(cexSwapData) + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" + + // When + val result = callOnSwap(isTangemPayWithdrawal = true) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TangemPayWithdrawalData::class.java) + val withdrawalData = result as SwapTransactionState.TangemPayWithdrawalData + assertThat(withdrawalData.cexAddress).isEqualTo("0xCexDepositAddress") + assertThat(withdrawalData.storeData).isNotNull() + assertThat(withdrawalData.exchangeData).isNotNull() + + coVerify(exactly = 0) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = any(), userWallet = any(), fee = any(), + ) + } + } + + @Test + fun `should return UnknownError for Cold demo card checked inside onSwapCex after getExchangeData`() = runTest { + // Given + // This demo check is at line ~818 of SwapInteractorImpl, AFTER getExchangeData succeeds. + // The dispatcher-level check is bypassed by returning false on the first call. + val coldWallet = mockk(relaxed = true) + + // First call → false (dispatcher check passes), second call → true (onSwapCex internal check) + every { isDemoCardUseCase(any()) } returnsMany listOf(false, true) + + val fromStatusCold = buildSwapCurrencyStatus(networkRawId = ethNetwork).let { + SwapCurrencyStatus(userWallet = coldWallet, status = it.status, account = it.account) + } + val cexSwapData = buildCexSwapDataModel() + coEvery { + repository.getExchangeData( + userWallet = any(), fromContractAddress = any(), fromNetwork = any(), + toContractAddress = any(), fromAddress = any(), toNetwork = any(), + fromAmount = any(), fromDecimals = any(), toDecimals = any(), + providerId = any(), rateType = any(), toAddress = any(), + expressOperationType = any(), refundAddress = any(), + ) + } returns cexSwapData.right() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatusCold, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = buildTxFee(), + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should return UnknownError when createTransferTransactionUseCase fails`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + coEvery { + createTransferTransactionUseCase( + amount = any(), fee = any(), memo = any(), + destination = any(), userWalletId = any(), network = any(), + ) + } returns RuntimeException("create transfer failed").left() + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should return UnknownError when txData extras is null but txExtraId is present`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel(txExtraId = "extra-id-required") + stubGetExchangeData(cexSwapData) + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + // When + val result = callOnSwap() + + // Then — extras == null AND txExtraId != null → UnknownError + assertThat(result).isInstanceOf(SwapTransactionState.Error.UnknownError::class.java) + } + + @Test + fun `should invoke createAndSendGaslessTransactionUseCase when FeeComponent with Token and LoadedExtended`() = + runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val tokenCurrencyStatus = buildSwapCurrencyStatus(networkRawId = ethNetwork, isCoin = false) + val extendedFee = mockk(relaxed = true) + val gaslessFee = TxFee.FeeComponent( + fee = mockk(relaxed = true), + transactionFeeResult = TransactionFeeResult.LoadedExtended(extendedFee), + selectedToken = tokenCurrencyStatus.status, + ) + + coEvery { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = any(), userWallet = any(), fee = any(), + ) + } returns "0xgasless-hash".right() + + // When + val result = sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = gaslessFee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + createAndSendGaslessTransactionUseCase.invoke( + transactionData = any(), userWallet = any(), fee = extendedFee, + ) + } + coVerify(exactly = 0) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + } + + @Test + fun `should invoke sendTransactionUseCase when FeeComponent but selectedToken is null`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val feeNoToken = TxFee.FeeComponent( + fee = mockk(relaxed = true), + transactionFeeResult = TransactionFeeResult.Loaded(mockk(relaxed = true)), + selectedToken = null, + ) + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xhash-notgasless".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = feeNoToken, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase.invoke(any(), any(), any()) + } + } + + @Test + fun `should invoke sendTransactionUseCase for Legacy fee`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val legacyFee = buildTxFeeLegacy() + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xlegacy-hash".right() + + // When + sut.onSwap( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + swapProvider = cexProvider, + swapData = null, + amountToSwap = "1.0", + includeFeeInAmount = IncludeFeeInAmount.Excluded, + fee = legacyFee, + expressOperationType = com.tangem.domain.express.models.ExpressOperationType.SWAP, + isTangemPayWithdrawal = false, + ) + + // Then + coVerify(exactly = 1) { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase.invoke(any(), any(), any()) + } + } + + @Test + fun `should return TxSent and call all three side effects on CEX send success`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns "0xcex-hash".right() + + every { amountFormatter.formatSwapAmountToUI(any(), any()) } returns "1.0 ETH" + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.TxSent::class.java) + val txSent = result as SwapTransactionState.TxSent + assertThat(txSent.txHash).isEqualTo("0xcex-hash") + + coVerify(exactly = 1) { + repository.exchangeSent( + userWallet = any(), txId = any(), fromNetwork = any(), + fromAddress = any(), payInAddress = any(), + txHash = "0xcex-hash", payInExtraId = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), toUserWalletId = any(), + fromCryptoCurrency = any(), toCryptoCurrency = any(), + fromAccount = any(), toAccount = any(), transaction = any(), + ) + } + coVerify(exactly = 1) { + swapTransactionRepository.storeLastSwappedCryptoCurrencyId( + userWalletId = any(), cryptoCurrencyId = any(), + ) + } + } + + @Test + fun `should return TransactionError when CEX send fails`() = runTest { + // Given + val cexSwapData = buildCexSwapDataModel() + stubGetExchangeData(cexSwapData) + val txDataMock = mockk(relaxed = true) { + every { extras } returns null + } + stubCreateTransferTx(txDataMock) + + val sendError = SendTransactionError.DataError("connection reset") + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns sendError.left() + + // When + val result = callOnSwap() + + // Then + assertThat(result).isInstanceOf(SwapTransactionState.Error.TransactionError::class.java) + val txError = result as SwapTransactionState.Error.TransactionError + assertThat(txError.error).isEqualTo(sendError) + } + } +} + +// region — file-private builders + +private fun buildSwapDataModelDex( + txData: String = "dGVzdA==", + txValue: String? = "0", + toAmount: BigDecimal = BigDecimal("0.5"), +): SwapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(toAmount, 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal.ONE, 18), + toAmount = SwapAmount(toAmount, 18), + txValue = txValue, + txId = "tx-id-123", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = txData, + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), +) + +private fun buildTxFeeLegacy( + feeValue: BigDecimal = BigDecimal("0.001"), +): TxFee.Legacy = TxFee.Legacy( + feeValue = feeValue, + feeFiatFormatted = "$0.01", + feeCryptoFormatted = "0.001 ETH", + feeIncludeOtherNativeFee = feeValue, + feeFiatFormattedWithNative = "$0.01", + feeCryptoFormattedWithNative = "0.001 ETH", + cryptoSymbol = "ETH", + feeType = FeeType.NORMAL, + fee = mockk(relaxed = true), +) + +// endregion \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt new file mode 100644 index 0000000000..b560b6cfa8 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplStoreSwapTransactionTest.kt @@ -0,0 +1,164 @@ +package com.tangem.feature.swap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.ExchangeStatus +import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel +import com.tangem.feature.swap.domain.models.domain.SavedSwapTransactionModel +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import io.mockk.clearMocks +import io.mockk.coVerify +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Tests for [SwapInteractorImpl.storeSwapTransaction]. + * + * Behavior: + * - Delegates to [SwapTransactionRepository.storeTransaction] with fields derived from + * the from/to currency statuses, the amount, the provider, and the [SwapDataModel]. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapInteractorImplStoreSwapTransactionTest : SwapInteractorImplTestBase() { + + @BeforeEach + fun resetSwapTransactionRepository() { + clearMocks(swapTransactionRepository) + } + + @Test + fun `should delegate to swapTransactionRepository storeTransaction with correct fields`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus( + networkRawId = Blockchain.Ethereum.toNetworkId(), + isCoin = true, + ) + val toStatus = buildSwapCurrencyStatus( + networkRawId = Blockchain.Bitcoin.toNetworkId(), + isCoin = true, + ) + val amount = SwapAmount(value = BigDecimal("1.25"), decimals = 18) + val provider = buildSwapProvider(type = ExchangeProviderType.DEX, providerId = "dex-store") + val swapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(BigDecimal("0.42"), 8), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal("1.25"), 18), + toAmount = SwapAmount(BigDecimal("0.42"), 8), + txValue = "0", + txId = "persisted-tx-id", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = "dGVzdA==", + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), + ) + val timestamp = 1_700_000_000L + + val transactionSlot = slot() + + // When + sut.storeSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = amount, + swapProvider = provider, + swapDataModel = swapDataModel, + timestamp = timestamp, + txExternalUrl = "https://explorer/tx", + txExternalId = "ext-id-1", + averageDuration = 120, + ) + + // Then + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), + toUserWalletId = any(), + fromCryptoCurrency = any(), + toCryptoCurrency = any(), + fromAccount = any(), + toAccount = any(), + transaction = capture(transactionSlot), + ) + } + + val captured = transactionSlot.captured + assertThat(captured.txId).isEqualTo("persisted-tx-id") + assertThat(captured.provider).isEqualTo(provider) + assertThat(captured.timestamp).isEqualTo(timestamp) + assertThat(captured.fromCryptoAmount).isEqualTo(BigDecimal("1.25")) + assertThat(captured.toCryptoAmount).isEqualTo(BigDecimal("0.42")) + val status = requireNotNull(captured.status) { "status should not be null" } + assertThat(status.providerId).isEqualTo("dex-store") + assertThat(status.status).isEqualTo(ExchangeStatus.New) + assertThat(status.txExternalUrl).isEqualTo("https://explorer/tx") + assertThat(status.txExternalId).isEqualTo("ext-id-1") + assertThat(status.averageDuration).isEqualTo(120) + } + + @Test + fun `should accept null txExternalUrl and txExternalId and averageDuration`() = runTest { + // Given + val fromStatus = buildSwapCurrencyStatus() + val toStatus = buildSwapCurrencyStatus() + val amount = SwapAmount(BigDecimal("0.5"), 18) + val provider = buildSwapProvider() + val swapDataModel = SwapDataModel( + toTokenAmount = SwapAmount(BigDecimal("0.1"), 18), + transaction = ExpressTransactionModel.DEX( + fromAmount = SwapAmount(BigDecimal("0.5"), 18), + toAmount = SwapAmount(BigDecimal("0.1"), 18), + txValue = "0", + txId = "tx-id-2", + txTo = "0xRecipient", + txExtraId = null, + txFrom = "0xSender", + txData = "dGVzdA==", + otherNativeFeeWei = null, + gas = BigInteger.valueOf(21_000L), + ), + ) + + val transactionSlot = slot() + + // When + sut.storeSwapTransaction( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + amount = amount, + swapProvider = provider, + swapDataModel = swapDataModel, + timestamp = 1L, + txExternalUrl = null, + txExternalId = null, + averageDuration = null, + ) + + // Then + coVerify(exactly = 1) { + swapTransactionRepository.storeTransaction( + fromUserWalletId = any(), + toUserWalletId = any(), + fromCryptoCurrency = any(), + toCryptoCurrency = any(), + fromAccount = any(), + toAccount = any(), + transaction = capture(transactionSlot), + ) + } + val status = requireNotNull(transactionSlot.captured.status) + assertThat(status.txExternalUrl).isNull() + assertThat(status.txExternalId).isNull() + assertThat(status.averageDuration).isNull() + } +} \ No newline at end of file diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt new file mode 100644 index 0000000000..ba41e6a388 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -0,0 +1,403 @@ +package com.tangem.feature.swap.domain + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.appcurrency.repository.AppCurrencyRepository +import com.tangem.domain.demo.IsDemoCardUseCase +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.express.models.ExpressRateType +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapPairModel +import com.tangem.domain.swap.usecase.GetSwapPairUseCase +import com.tangem.domain.tokens.GetAssetRequirementsUseCase +import com.tangem.domain.tokens.GetCurrencyCheckUseCase +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase +import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.feature.swap.domain.api.SwapRepository +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.AmountFormatter +import com.tangem.feature.swap.domain.models.ui.TxFee +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import java.math.BigDecimal + +/** + * Base class that wires all ~30 dependencies of [SwapInteractorImpl] as relaxed MockK mocks. + * Extend this in every test class and override individual stubs in `@BeforeEach` or within tests. + */ +internal open class SwapInteractorImplTestBase { + + // region — mocked dependencies + + protected val repository: SwapRepository = mockk(relaxed = true) + protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true) + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true) + protected val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + protected val createTransactionUseCase: CreateTransactionUseCase = mockk(relaxed = true) + protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true) + protected val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true) + protected val isDemoCardUseCase: IsDemoCardUseCase = mockk(relaxed = true) + protected val quotesRepository: QuotesRepository = mockk(relaxed = true) + protected val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk(relaxed = true) + protected val swapTransactionRepository: SwapTransactionRepository = mockk(relaxed = true) + private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true) + private val appCurrencyRepository: AppCurrencyRepository = mockk(relaxed = true) + protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) + protected val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true) + protected val validateTransactionUseCase: ValidateTransactionUseCase = mockk(relaxed = true) + protected val estimateFeeUseCase: EstimateFeeUseCase = mockk(relaxed = true) + protected val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase = mockk(relaxed = true) + protected val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase = mockk(relaxed = true) + private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) + protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = + mockk(relaxed = true) + protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) + private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true) + protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk(relaxed = true) + protected val getAssetRequirementsUseCase: GetAssetRequirementsUseCase = mockk(relaxed = true) + protected val amountFormatter: AmountFormatter = mockk(relaxed = true) + protected val rampStateManager: RampStateManager = mockk(relaxed = true) + protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = + mockk(relaxed = true) + protected val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + protected val getAllowanceInfoUseCase: GetAllowanceInfoUseCase = mockk(relaxed = true) + protected val getSwapPairUseCase: GetSwapPairUseCase = mockk(relaxed = true) + + // endregion + + protected val sut: SwapInteractorImpl by lazy { + SwapInteractorImpl( + repository = repository, + allowPermissionsHandler = allowPermissionsHandler, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, + sendTransactionUseCase = sendTransactionUseCase, + createTransactionUseCase = createTransactionUseCase, + createTransferTransactionUseCase = createTransferTransactionUseCase, + createTransactionExtrasUseCase = createTransactionExtrasUseCase, + isDemoCardUseCase = isDemoCardUseCase, + quotesRepository = quotesRepository, + multiQuoteStatusFetcher = multiQuoteStatusFetcher, + swapTransactionRepository = swapTransactionRepository, + currencyChecksRepository = currencyChecksRepository, + appCurrencyRepository = appCurrencyRepository, + currenciesRepository = currenciesRepository, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + validateTransactionUseCase = validateTransactionUseCase, + estimateFeeUseCase = estimateFeeUseCase, + estimateFeeForTokenUseCase = estimateFeeForTokenUseCase, + estimateFeeForGaslessTxUseCase = estimateFeeForGaslessTxUseCase, + getFeeForTokenUseCase = getFeeForTokenUseCase, + createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, + getFeeUseCase = getFeeUseCase, + getEthSpecificFeeUseCase = getEthSpecificFeeUseCase, + getCurrencyCheckUseCase = getCurrencyCheckUseCase, + getAssetRequirementsUseCase = getAssetRequirementsUseCase, + amountFormatter = amountFormatter, + rampStateManager = rampStateManager, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + walletManagersFacade = walletManagersFacade, + getAllowanceInfoUseCase = getAllowanceInfoUseCase, + getSwapPairUseCase = getSwapPairUseCase, + ) + } + + /** + * Clears recorded calls and stubbed answers on all MockK mocks AND releases any + * `mockkStatic` / `mockkObject` declarations between tests. + * + * - `clearAllMocks()` wipes recorded calls and stubbed answers; relaxed mocks remain relaxed + * (creation-time property). Each test must (re)stub any required behavior in its own + * `@BeforeEach` or test body. + * - `unmockkAll()` releases static/object mocks set up inline by some tests + * (e.g. `mockkStatic(Base64::class)`, `mockkObject(SolanaTransactionHelper)`) so leaks + * do not propagate across tests within the same class. + */ + @AfterEach + open fun clearMocksAfterEachTest() { + clearAllMocks() + unmockkAll() + } + + /** + * Defensive shutdown hook — releases any remaining `mockkStatic` / `mockkObject` declarations + * after the entire test class finishes, in case `@AfterEach` was bypassed (e.g. JVM shutdown + * during a hard crash). + * + * Requires `@TestInstance(Lifecycle.PER_CLASS)` on every subclass — already the case across + * all `SwapInteractorImpl*Test` classes. + */ + @AfterAll + open fun releaseStaticMocksAfterAllTests() { + unmockkAll() + } +} + +// region — Test Builders + +/** + * Builds a [SwapCurrencyStatus] backed entirely by relaxed mocks. + * + * The [Network] mock is fully relaxed — [Network.rawId] is stubbed to return [networkRawId]. + * The extension function [com.tangem.blockchainsdk.utils.toBlockchain] is not stubbed here; + * call-sites that need a specific Blockchain should use [io.mockk.mockkStatic] around the test. + * + * @param networkRawId raw network id — use `Blockchain.Ethereum.toNetworkId()` for EVM + * @param contractAddress "0" for native coins, a real contract address for tokens + * @param isCoin true to make the currency a [CryptoCurrency.Coin], false for [CryptoCurrency.Token] + * @param amount token balance to expose via [CryptoCurrencyStatus.Value.amount] + */ +internal fun buildSwapCurrencyStatus( + networkRawId: String = Blockchain.Ethereum.toNetworkId(), + contractAddress: String = "0", + isCoin: Boolean = true, + amount: BigDecimal = BigDecimal("1"), + decimals: Int = 18, + userWalletId: UserWalletId = UserWalletId(stringValue = "deadbeef"), + yieldSupplyActive: Boolean = false, +): SwapCurrencyStatus { + val networkId = mockk(relaxed = true) { + every { rawId } returns Network.RawID(networkRawId) + } + val network = mockk(relaxed = true) { + every { rawId } returns networkRawId + every { id } returns networkId + every { derivationPath } returns Network.DerivationPath.None + } + + val currencyId = mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID(contractAddress) + } + val currency: CryptoCurrency = if (isCoin) { + mockk(relaxed = true) { + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { this@mockk.id } returns currencyId + } + } else { + mockk(relaxed = true) { + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { this@mockk.contractAddress } returns contractAddress + every { this@mockk.id } returns currencyId + } + } + + val networkAddress = mockk(relaxed = true) { + every { defaultAddress } returns NetworkAddress.Address( + value = "0xTestAddress", + type = NetworkAddress.Address.Type.Primary, + ) + } + + val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) { + mockk(relaxed = true) { + every { isActive } returns true + } + } else { + null + } + + val statusValue = mockk(relaxed = true) { + every { this@mockk.amount } returns amount + every { this@mockk.networkAddress } returns networkAddress + every { this@mockk.pendingTransactions } returns emptySet() + every { this@mockk.yieldSupplyStatus } returns maybeYield + } + + val cryptoCurrencyStatus = CryptoCurrencyStatus( + currency = currency, + value = statusValue, + ) + + val userWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + val account = mockk(relaxed = true) { + every { accountId } returns mockk(relaxed = true) + } + + return SwapCurrencyStatus( + userWallet = userWallet, + status = cryptoCurrencyStatus, + account = account, + ) +} + +/** + * Builds a mocked [CryptoCurrency.Coin] with a stubbed network. Used where APIs require the concrete Coin subtype. + */ +internal fun buildCoinCurrency( + networkRawId: String = Blockchain.Ethereum.toNetworkId(), + decimals: Int = 18, +): CryptoCurrency.Coin { + val networkId = mockk(relaxed = true) { + every { rawId } returns Network.RawID(networkRawId) + } + val network = mockk(relaxed = true) { + every { rawId } returns networkRawId + every { id } returns networkId + every { derivationPath } returns Network.DerivationPath.None + } + val currencyId = mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("0") + } + return mockk(relaxed = true) { + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { this@mockk.id } returns currencyId + } +} + +/** + * Builds a [SwapProvider] for a given [ExchangeProviderType]. + */ +internal fun buildSwapProvider( + type: ExchangeProviderType = ExchangeProviderType.DEX, + providerId: String = "test-provider-${type.name}", +): SwapProvider = SwapProvider( + providerId = providerId, + rateTypes = listOf(RateType.FLOAT), + name = "TestProvider-${type.name}", + type = type, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, +) + +/** + * Builds a [TxFee.FeeComponent] wrapping a [Fee.Common] with the given fiat-equivalent amount. + */ +internal fun buildTxFee( + feeValue: BigDecimal = BigDecimal("0.001"), + selectedToken: CryptoCurrencyStatus? = null, +): TxFee.FeeComponent { + val amount = mockk(relaxed = true) { + every { value } returns feeValue + } + val fee = mockk(relaxed = true) { + every { this@mockk.amount } returns amount + } + return TxFee.FeeComponent( + fee = fee, + transactionFeeResult = TransactionFeeResult.Loaded( + fee = mockk(relaxed = true), + ), + selectedToken = selectedToken, + ) +} + +/** + * Builds a [TxFeeSealedState.Component] wrapping a [TxFee.FeeComponent]. + */ +internal fun buildTxFeeSealedState( + feeValue: BigDecimal = BigDecimal("0.001"), + selectedToken: CryptoCurrencyStatus? = null, +): TxFeeSealedState = TxFeeSealedState.Component( + txFee = buildTxFee(feeValue = feeValue, selectedToken = selectedToken), +) + +/** + * Builds a [SwapPairLeast] with matching from/to network+contract pairs. + */ +internal fun buildSwapPairLeast( + fromNetwork: String = Blockchain.Ethereum.toNetworkId(), + fromContract: String = "0", + toNetwork: String = Blockchain.Bitcoin.toNetworkId(), + toContract: String = "0", + providers: List = listOf(buildSwapProvider()), +): SwapPairLeast = SwapPairLeast( + from = LeastTokenInfo(contractAddress = fromContract, network = fromNetwork), + to = LeastTokenInfo(contractAddress = toContract, network = toNetwork), + providers = providers, +) + +/** + * Builds a [QuoteModel] with optional allowance contract. + */ +internal fun buildQuoteModel( + toAmount: BigDecimal = BigDecimal("0.5"), + decimals: Int = 18, + allowanceContract: String? = null, +): QuoteModel = QuoteModel( + toTokenAmount = SwapAmount(toAmount, decimals), + allowanceContract = allowanceContract, +) + +/** + * Builds an [ExpressProvider] — used by [GetSwapPairUseCase] results. + */ +internal fun buildExpressProvider( + providerId: String = "express-provider", + type: ExpressProviderType = ExpressProviderType.DEX, +): ExpressProvider = ExpressProvider( + providerId = providerId, + rateTypes = listOf(ExpressRateType.Float), + name = "ExpressProvider-${type.name}", + type = type, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + isRecommended = false, + slippage = null, + isExtraIdSupported = false, +) + +/** + * Builds a [SwapPairModel] — used as the result type of [GetSwapPairUseCase]. + */ +internal fun buildSwapPairModel( + fromNetworkRawId: String = Blockchain.Ethereum.toNetworkId(), + fromContractAddress: String = "0", + toNetworkRawId: String = Blockchain.Bitcoin.toNetworkId(), + toContractAddress: String = "0", + providers: List = listOf(buildExpressProvider()), +): SwapPairModel { + val fromCurrencyStatus = buildSwapCurrencyStatus( + networkRawId = fromNetworkRawId, + contractAddress = fromContractAddress, + ) + val toCurrencyStatus = buildSwapCurrencyStatus( + networkRawId = toNetworkRawId, + contractAddress = toContractAddress, + ) + return SwapPairModel( + from = fromCurrencyStatus.status, + to = toCurrencyStatus.status, + providers = providers, + ) +} + +// endregion \ No newline at end of file