Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-05 14:48:28 +05:00
commit 0091d0131f
10 changed files with 3490 additions and 0 deletions

View file

@ -9,6 +9,14 @@ plugins {
android {
namespace = "com.tangem.features.domain.swap"
testOptions {
unitTests.isIncludeAndroidResources = false
}
}
tasks.withType<Test>().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)
}

View file

@ -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<Set<CryptoCurrency.RawID>>().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<TransactionExtras>(relaxed = true).right()
coEvery {
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
} returns mockk<TransactionFee.Single>(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<String>(), 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<String>(), any()) } returns ByteArray(931)
io.mockk.mockkObject(SolanaTransactionHelper)
every {
SolanaTransactionHelper.removeSignaturesPlaceholders(any())
} returns ByteArray(931)
val dexProvider = buildSwapProvider(ExchangeProviderType.DEX)
val coldWallet = mockk<UserWallet.Cold>(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

View file

@ -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)
}
}
}

View file

@ -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<CryptoCurrency.Coin>(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<CryptoCurrency.Coin>(relaxed = true) {
every { network } returns mockk<Network>(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)
}
}

View file

@ -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<com.tangem.domain.swap.models.SwapPairModel>().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<com.tangem.domain.swap.models.SwapPairModel>().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)
}
}
}
}

View file

@ -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<CryptoCurrency.Coin>(relaxed = true) {
every { decimals } returns 18
}
val value = mockk<CryptoCurrencyStatus.Loaded>(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<CryptoCurrency.Coin>(relaxed = true) {
every { decimals } returns 8
}
val value = mockk<CryptoCurrencyStatus.Loading>(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<CryptoCurrency.Token>(relaxed = true) {
every { decimals } returns 6
}
val value = mockk<CryptoCurrencyStatus.Loaded>(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"))
}
}

View file

@ -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<GetFeeError, TransactionFeeExtended>]):
* - 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<GetFeeError, TransactionFee>]):
* - 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<CryptoCurrencyStatus>(relaxed = true)
val expectedFeeExtended = mockk<TransactionFeeExtended>(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<TransactionFeeExtended>(relaxed = true)
val capturedAmount = slot<BigDecimal>()
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<TransactionFeeExtended>(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(),
)
}
}
}
}

View file

@ -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<SavedSwapTransactionModel>()
// 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<SavedSwapTransactionModel>()
// 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()
}
}

View file

@ -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<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
}
val network = mockk<Network>(relaxed = true) {
every { rawId } returns networkRawId
every { id } returns networkId
every { derivationPath } returns Network.DerivationPath.None
}
val currencyId = mockk<CryptoCurrency.ID>(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID(contractAddress)
}
val currency: CryptoCurrency = if (isCoin) {
mockk<CryptoCurrency.Coin>(relaxed = true) {
every { this@mockk.network } returns network
every { this@mockk.decimals } returns decimals
every { this@mockk.id } returns currencyId
}
} else {
mockk<CryptoCurrency.Token>(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<NetworkAddress>(relaxed = true) {
every { defaultAddress } returns NetworkAddress.Address(
value = "0xTestAddress",
type = NetworkAddress.Address.Type.Primary,
)
}
val maybeYield: YieldSupplyStatus? = if (yieldSupplyActive) {
mockk<YieldSupplyStatus>(relaxed = true) {
every { isActive } returns true
}
} else {
null
}
val statusValue = mockk<CryptoCurrencyStatus.Loaded>(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<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
val account = mockk<Account>(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<Network.ID>(relaxed = true) {
every { rawId } returns Network.RawID(networkRawId)
}
val network = mockk<Network>(relaxed = true) {
every { rawId } returns networkRawId
every { id } returns networkId
every { derivationPath } returns Network.DerivationPath.None
}
val currencyId = mockk<CryptoCurrency.ID>(relaxed = true) {
every { rawCurrencyId } returns CryptoCurrency.RawID("0")
}
return mockk<CryptoCurrency.Coin>(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<Amount>(relaxed = true) {
every { value } returns feeValue
}
val fee = mockk<Fee.Common>(relaxed = true) {
every { this@mockk.amount } returns amount
}
return TxFee.FeeComponent(
fee = fee,
transactionFeeResult = TransactionFeeResult.Loaded(
fee = mockk<TransactionFee.Single>(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<SwapProvider> = 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<ExpressProvider> = 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