Updated on 2026-08-14
This commit is contained in:
commit
0091d0131f
10 changed files with 3490 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue