Updated on 2026-08-14
This commit is contained in:
parent
a50cb15228
commit
e2cd6813a0
19 changed files with 4599 additions and 0 deletions
|
|
@ -0,0 +1,198 @@
|
|||
package com.tangem.features.yield.supply.impl.active.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
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.yield.supply.models.YieldSupplyMaxFee
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyActiveFeeContentTransformerTest {
|
||||
|
||||
private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(analyticsHandler)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee below max WHEN transform THEN not high fee and computed fee texts`() {
|
||||
// Arrange — fee 1, maxToken 2, maxFiat 4, fiatRate 1
|
||||
val transformer = createTransformer(feeValue = BigDecimal("1"), tokenMaxFee = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — currentFee is the token fiat fee (feeValue * fiatRate); feeDescription holds the 4 args in order
|
||||
val expectedFiatFee = fiatText(BigDecimal("1").multiply(BigDecimal("1")))
|
||||
assertThat(result.isHighFee).isFalse()
|
||||
assertThat(result.currentFee).isEqualTo(stringReference(expectedFiatFee))
|
||||
assertThat(result.feeDescription).isEqualTo(
|
||||
resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_fee_note,
|
||||
formatArgs = wrappedList(
|
||||
stringReference(expectedFiatFee),
|
||||
stringReference(cryptoText(BigDecimal("1"))),
|
||||
stringReference(fiatText(BigDecimal("4"))),
|
||||
stringReference(cryptoText(BigDecimal("2"))),
|
||||
),
|
||||
),
|
||||
)
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee above max WHEN transform THEN high fee and analytics carries token and blockchain`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(feeValue = BigDecimal("3"), tokenMaxFee = BigDecimal("2"))
|
||||
val eventSlot = slot<AnalyticsEvent>()
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isHighFee).isTrue()
|
||||
verify(exactly = 1) { analyticsHandler.send(capture(eventSlot)) }
|
||||
val event = eventSlot.captured as YieldSupplyAnalytics.NoticeHighNetworkFee
|
||||
assertThat(event.token).isEqualTo("TTK")
|
||||
assertThat(event.blockchain).isEqualTo("Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee equal to max WHEN transform THEN not high fee`() {
|
||||
// Arrange — boundary: comparison is strictly greater-than
|
||||
val transformer = createTransformer(feeValue = BigDecimal("2"), tokenMaxFee = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isHighFee).isFalse()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing fiat rate WHEN transform THEN current fee is the placeholder and high fee resolved by crypto`() {
|
||||
// Arrange — null fiat rate: fiat fee text falls back to the placeholder, high-fee logic unaffected
|
||||
val transformer = createTransformer(
|
||||
feeValue = BigDecimal("3"),
|
||||
tokenMaxFee = BigDecimal("2"),
|
||||
fiatRate = null,
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — placeholder differs from a populated fiat value, proving the null branch was taken
|
||||
assertThat(result.currentFee).isEqualTo(stringReference(fiatText(null)))
|
||||
assertThat(result.isHighFee).isTrue()
|
||||
verify(exactly = 1) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) }
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun createTransformer(
|
||||
feeValue: BigDecimal,
|
||||
tokenMaxFee: BigDecimal,
|
||||
fiatRate: BigDecimal? = BigDecimal("1"),
|
||||
): YieldSupplyActiveFeeContentTransformer = YieldSupplyActiveFeeContentTransformer(
|
||||
cryptoCurrencyStatus = status(fiatRate = fiatRate),
|
||||
appCurrency = appCurrency,
|
||||
feeValue = feeValue,
|
||||
maxNetworkFee = YieldSupplyMaxFee(
|
||||
nativeMaxFee = BigDecimal("0.01"),
|
||||
tokenMaxFee = tokenMaxFee,
|
||||
fiatMaxFee = BigDecimal("4"),
|
||||
),
|
||||
analyticsHandler = analyticsHandler,
|
||||
)
|
||||
|
||||
private fun status(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun emptyContent(): YieldSupplyActiveContentUM = YieldSupplyActiveContentUM(
|
||||
totalEarnings = stringReference(""),
|
||||
availableBalance = null,
|
||||
providerTitle = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
subtitleLink = stringReference(""),
|
||||
notifications = persistentListOf(),
|
||||
minAmount = null,
|
||||
currentFee = null,
|
||||
feeDescription = null,
|
||||
minFeeDescription = null,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
package com.tangem.features.yield.supply.impl.active.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.ui.components.notifications.NotificationConfig
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.common.ui.notifications.NotificationUM
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
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.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyActiveMinAmountTransformerTest {
|
||||
|
||||
private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
private var approveClicked = false
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(analyticsHandler)
|
||||
approveClicked = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN spending not allowed and nothing un-supplied WHEN transform THEN approval notification and min amount texts`() {
|
||||
// Arrange
|
||||
val status = status(amount = BigDecimal("5"), isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal("5"))
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — minAmount uses the fiat value (minAmount * fiatRate); minFeeDescription carries [fiat, crypto] in order
|
||||
val expectedMinFiat = fiatText(MIN_AMOUNT.multiply(BigDecimal("1")))
|
||||
val expectedMinCrypto = cryptoText(MIN_AMOUNT)
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications.first()).isInstanceOf(NotificationUM.Error::class.java)
|
||||
assertThat(result.minAmount).isEqualTo(stringReference(expectedMinFiat))
|
||||
assertThat(result.minFeeDescription).isEqualTo(
|
||||
resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_min_amount_note,
|
||||
formatArgs = wrappedList(expectedMinFiat, expectedMinCrypto),
|
||||
),
|
||||
)
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN spending allowed and un-supplied above dust WHEN transform THEN not-supplied notification with amount and analytics`() {
|
||||
// Arrange — un-supplied = amount(10) - protocolBalance(1) = 9
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
val eventSlot = slot<AnalyticsEvent>()
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
val notification = result.notifications.first() as NotificationUM.Info.YieldSupplyNotAllAmountSupplied
|
||||
assertThat(notification.symbol).isEqualTo(TOKEN_SYMBOL)
|
||||
assertThat(notification.formattedAmount).isEqualTo(notSuppliedText(BigDecimal("9")))
|
||||
verify(exactly = 1) { analyticsHandler.send(capture(eventSlot)) }
|
||||
val event = eventSlot.captured as YieldSupplyAnalytics.NoticeAmountNotDeposited
|
||||
assertThat(event.token).isEqualTo(TOKEN_SYMBOL)
|
||||
assertThat(event.blockchain).isEqualTo("Ethereum")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN spending allowed and fully supplied WHEN transform THEN no notifications`() {
|
||||
// Arrange
|
||||
val status = status(amount = BigDecimal("5"), isAllowedToSpend = true, effectiveProtocolBalance = BigDecimal("5"))
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN un-supplied amount below dust threshold WHEN transform THEN no not-supplied notification`() {
|
||||
// Arrange — un-supplied = 1 (fiat), dust threshold = 5 → below threshold
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("9"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("5"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN un-supplied fiat equals dust threshold WHEN transform THEN not-supplied notification shown`() {
|
||||
// Arrange — boundary: shouldShowNotSuppliedNotification uses >=, so equality must show the notification
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal("5"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("5"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — un-supplied fiat = (10-5)*1 = 5 == dust 5
|
||||
assertThat(result.notifications).hasSize(1)
|
||||
assertThat(result.notifications.first())
|
||||
.isInstanceOf(NotificationUM.Info.YieldSupplyNotAllAmountSupplied::class.java)
|
||||
verify(exactly = 1) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN supply inactive WHEN transform THEN no not-supplied notification even if balance differs`() {
|
||||
// Arrange — isActive=false short-circuits notSupplied calculation
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
isActive = false,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing fiat rate WHEN transform THEN min amount is the placeholder and no not-supplied notification`() {
|
||||
// Arrange — null fiat rate: fiat min amount cannot be computed, not-supplied calc is skipped
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = true,
|
||||
isActive = false,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = null,
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — minAmount falls back to the null-rate placeholder
|
||||
assertThat(result.minAmount).isEqualTo(stringReference(fiatText(null)))
|
||||
assertThat(result.notifications).isEmpty()
|
||||
verify(exactly = 0) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN approval needed and un-supplied above dust WHEN transform THEN both notifications in order`() {
|
||||
// Arrange
|
||||
val status = status(
|
||||
amount = BigDecimal("10"),
|
||||
isAllowedToSpend = false,
|
||||
effectiveProtocolBalance = BigDecimal("1"),
|
||||
fiatRate = BigDecimal("1"),
|
||||
)
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
|
||||
// Assert — approval first, then not-supplied (listOfNotNull order)
|
||||
assertThat(result.notifications).hasSize(2)
|
||||
assertThat(result.notifications[0]).isInstanceOf(NotificationUM.Error::class.java)
|
||||
assertThat(result.notifications[1])
|
||||
.isInstanceOf(NotificationUM.Info.YieldSupplyNotAllAmountSupplied::class.java)
|
||||
verify(exactly = 1) { analyticsHandler.send(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN approval notification WHEN its button clicked THEN onApprove fires`() {
|
||||
// Arrange
|
||||
val status = status(amount = BigDecimal("5"), isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal("5"))
|
||||
val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(emptyContent())
|
||||
val button = (result.notifications.first() as NotificationUM.Error)
|
||||
.config.buttonsState as NotificationConfig.ButtonsState.PrimaryButtonConfig
|
||||
button.onClick()
|
||||
|
||||
// Assert
|
||||
assertThat(approveClicked).isTrue()
|
||||
}
|
||||
|
||||
private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) }
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun notSuppliedText(value: BigDecimal): String = value.format { crypto(symbol = "", decimals = token.decimals) }
|
||||
|
||||
private fun createTransformer(
|
||||
status: CryptoCurrencyStatus,
|
||||
dustMinAmount: BigDecimal,
|
||||
): YieldSupplyActiveMinAmountTransformer = YieldSupplyActiveMinAmountTransformer(
|
||||
cryptoCurrencyStatus = status,
|
||||
appCurrency = appCurrency,
|
||||
minAmount = MIN_AMOUNT,
|
||||
dustMinAmount = dustMinAmount,
|
||||
analyticsHandler = analyticsHandler,
|
||||
onApprove = { approveClicked = true },
|
||||
)
|
||||
|
||||
private fun status(
|
||||
amount: BigDecimal,
|
||||
isAllowedToSpend: Boolean,
|
||||
isActive: Boolean = true,
|
||||
effectiveProtocolBalance: BigDecimal? = null,
|
||||
fiatRate: BigDecimal? = BigDecimal("1"),
|
||||
): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = amount,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = isActive,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = isAllowedToSpend,
|
||||
effectiveProtocolBalance = effectiveProtocolBalance,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun emptyContent(): YieldSupplyActiveContentUM = YieldSupplyActiveContentUM(
|
||||
totalEarnings = stringReference(""),
|
||||
availableBalance = null,
|
||||
providerTitle = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
subtitleLink = stringReference(""),
|
||||
notifications = persistentListOf(),
|
||||
minAmount = null,
|
||||
currentFee = null,
|
||||
feeDescription = null,
|
||||
minFeeDescription = null,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = TOKEN_SYMBOL,
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_SYMBOL = "TTK"
|
||||
val MIN_AMOUNT: BigDecimal = BigDecimal("2")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.tangem.features.yield.supply.impl.chart.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetChartUseCase
|
||||
import com.tangem.features.yield.supply.impl.chart.DefaultYieldSupplyChartComponent
|
||||
import com.tangem.features.yield.supply.impl.chart.entity.YieldSupplyChartUM
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyChartModelTest {
|
||||
|
||||
private val getChartUseCase: YieldSupplyGetChartUseCase = mockk()
|
||||
private val callback: DefaultYieldSupplyChartComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(getChartUseCase, callback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN chart data with values above one WHEN model created THEN Data state with integer percent format`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = listOf(2.0, 5.0, 10.0)).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
val state = model.uiState.value
|
||||
assertThat(state).isInstanceOf(YieldSupplyChartUM.Data::class.java)
|
||||
val data = state as YieldSupplyChartUM.Data
|
||||
assertThat(data.chartData.percentFormat).isEqualTo("%.0f")
|
||||
assertThat(data.monthLables).hasSize(MONTH_LABELS_COUNT)
|
||||
verify(exactly = 1) { callback.onStartLoading() }
|
||||
verify(exactly = 1) { callback.onSuccessLoad() }
|
||||
verify(exactly = 0) { callback.onLoadFail() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN chart data with values below one WHEN model created THEN Data state with one-decimal percent format`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = listOf(0.2, 0.5, 0.9)).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
val data = model.uiState.value as YieldSupplyChartUM.Data
|
||||
assertThat(data.chartData.percentFormat).isEqualTo("%.1f")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty chart data WHEN model created THEN Error state and load fail callback`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = emptyList()).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Error::class.java)
|
||||
verify(exactly = 1) { callback.onStartLoading() }
|
||||
verify(exactly = 1) { callback.onLoadFail() }
|
||||
verify(exactly = 0) { callback.onSuccessLoad() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN use case fails WHEN model created THEN Error state and load fail callback`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getChartUseCase(any()) } returns IllegalStateException("boom").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Error::class.java)
|
||||
verify(exactly = 1) { callback.onLoadFail() }
|
||||
verify(exactly = 0) { callback.onSuccessLoad() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN error state WHEN retry invoked AND data available THEN recovers to Data state`() = runTest {
|
||||
// Arrange — first call fails, retry succeeds
|
||||
coEvery { getChartUseCase(any()) } returnsMany listOf(
|
||||
IllegalStateException("boom").left(),
|
||||
chartData(y = listOf(2.0, 5.0)).right(),
|
||||
)
|
||||
val model = createModel()
|
||||
val error = model.uiState.value as YieldSupplyChartUM.Error
|
||||
|
||||
// Act
|
||||
error.onRetry()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Data::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no callback WHEN model created with data THEN Data state without crash`() = runTest {
|
||||
// Arrange — Params.callback is optional; model must tolerate its absence
|
||||
coEvery { getChartUseCase(any()) } returns chartData(y = listOf(2.0, 5.0)).right()
|
||||
|
||||
// Act
|
||||
val model = createModel(callback = null)
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Data::class.java)
|
||||
}
|
||||
|
||||
private fun createModel(
|
||||
callback: DefaultYieldSupplyChartComponent.ModelCallback? = this.callback,
|
||||
): YieldSupplyChartModel = YieldSupplyChartModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
DefaultYieldSupplyChartComponent.Params(cryptoCurrency = createToken(), callback = callback),
|
||||
),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
yieldSupplyGetChartUseCase = getChartUseCase,
|
||||
)
|
||||
|
||||
private fun chartData(y: List<Double>): YieldSupplyMarketChartData =
|
||||
YieldSupplyMarketChartData(y = y, x = y.indices.map { it.toDouble() }, avr = 1.0)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MONTH_LABELS_COUNT = 5
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
package com.tangem.features.yield.supply.impl.entry.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.none
|
||||
import arrow.core.right
|
||||
import arrow.core.some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Route
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
|
||||
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.account.models.AccountStatusList
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
|
||||
import com.tangem.domain.tokens.model.details.NavigationAction
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
|
||||
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyEntryModelTest {
|
||||
|
||||
private val router: Router = mockk(relaxed = true)
|
||||
private val enterStatusUseCase: YieldSupplyEnterStatusUseCase = mockk()
|
||||
private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
|
||||
private val isPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk()
|
||||
private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk()
|
||||
|
||||
private val accountStatusList: AccountStatusList = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
clearMocks(
|
||||
router, enterStatusUseCase, accountStatusListSupplier,
|
||||
isPromoEnabledUseCase, yieldSupplyFeatureToggles,
|
||||
)
|
||||
mockkObject(CryptoCurrencyStatusOperations)
|
||||
coEvery { accountStatusListSupplier.getSyncOrNull(USER_WALLET_ID) } returns accountStatusList
|
||||
every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkObject(CryptoCurrencyStatusOperations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status not found WHEN created THEN pops without navigating`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(none())
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { router.pop(any()) }
|
||||
verify(exactly = 0) { router.replaceCurrent(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency is not a token WHEN created THEN pops without navigating`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
|
||||
// Act
|
||||
createModel(currency = coin())
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { router.pop(any()) }
|
||||
verify(exactly = 0) { router.replaceCurrent(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending enter status and active yield WHEN created THEN navigates to currency details active`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = true).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns pendingEnter().right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat(route).isInstanceOf(AppRoute.CurrencyDetails::class.java)
|
||||
assertThat((route as AppRoute.CurrencyDetails).navigationAction)
|
||||
.isEqualTo(NavigationAction.YieldSupply(isActive = true))
|
||||
assertThat(route.userWalletId).isEqualTo(USER_WALLET_ID)
|
||||
assertThat(route.currency).isEqualTo(token())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending enter status and inactive yield WHEN created THEN currency details with inactive flag`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns pendingEnter().right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat((route as AppRoute.CurrencyDetails).navigationAction)
|
||||
.isEqualTo(NavigationAction.YieldSupply(isActive = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no pending status and active yield WHEN created THEN navigates to Active route`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = true).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat(route).isInstanceOf(YieldSupplyEntryRoute.Active::class.java)
|
||||
assertThat((route as YieldSupplyEntryRoute.Active).cryptoCurrency).isEqualTo(token())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN enter status use case fails WHEN created THEN coerced to no pending and routes to Active`() = runTest {
|
||||
// Arrange — a Left is coerced to null by getOrNull, so it must NOT route to CurrencyDetails
|
||||
stubStatusLookup(status(isActive = true).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns Throwable("boom").left()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
assertThat(captureReplacedRoute()).isInstanceOf(YieldSupplyEntryRoute.Active::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no pending status and inactive yield with promo enabled WHEN created THEN Promo route promo-enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right()
|
||||
coEvery { isPromoEnabledUseCase(USER_WALLET_ID, any()) } returns true.right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat(route).isInstanceOf(YieldSupplyEntryRoute.Promo::class.java)
|
||||
assertThat((route as YieldSupplyEntryRoute.Promo).isPromoEnabled).isTrue()
|
||||
assertThat(route.apy).isEqualTo("5.0")
|
||||
assertThat(route.cryptoCurrency).isEqualTo(token())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN promo toggle disabled WHEN created THEN Promo route with promo disabled`() = runTest {
|
||||
// Arrange
|
||||
every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns false
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
val route = captureReplacedRoute()
|
||||
assertThat((route as YieldSupplyEntryRoute.Promo).isPromoEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN promo use case returns false WHEN created THEN Promo route with promo disabled`() = runTest {
|
||||
// Arrange
|
||||
stubStatusLookup(status(isActive = false).some())
|
||||
coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right()
|
||||
coEvery { isPromoEnabledUseCase(USER_WALLET_ID, any()) } returns false.right()
|
||||
|
||||
// Act
|
||||
createModel(currency = token())
|
||||
|
||||
// Assert
|
||||
assertThat((captureReplacedRoute() as YieldSupplyEntryRoute.Promo).isPromoEnabled).isFalse()
|
||||
}
|
||||
|
||||
private fun captureReplacedRoute(): Route {
|
||||
val slot = slot<Route>()
|
||||
verify { router.replaceCurrent(capture(slot), any()) }
|
||||
return slot.captured
|
||||
}
|
||||
|
||||
private fun stubStatusLookup(result: arrow.core.Option<CryptoCurrencyStatus>) {
|
||||
every {
|
||||
with(CryptoCurrencyStatusOperations) {
|
||||
accountStatusList.getCryptoCurrencyStatus(any<CryptoCurrency>())
|
||||
}
|
||||
} returns result
|
||||
}
|
||||
|
||||
private fun createModel(currency: CryptoCurrency): YieldSupplyEntryModel = YieldSupplyEntryModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyEntryComponent.Params(userWalletId = USER_WALLET_ID, cryptoCurrency = currency, apy = "5.0"),
|
||||
),
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
router = router,
|
||||
yieldSupplyEnterStatusUseCase = enterStatusUseCase,
|
||||
singleAccountStatusListSupplier = accountStatusListSupplier,
|
||||
isYieldBoostPromoEnabledForTokenUseCase = isPromoEnabledUseCase,
|
||||
yieldSupplyFeatureToggles = yieldSupplyFeatureToggles,
|
||||
)
|
||||
|
||||
private fun pendingEnter(): YieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txIds = listOf("0xTx"))
|
||||
|
||||
private fun status(isActive: Boolean): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token(),
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = isActive,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = null,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun token(): CryptoCurrency.Token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
|
||||
private fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_COIN",
|
||||
symbol = "ETH",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
private fun network(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val USER_WALLET_ID = UserWalletId("abcdef012345")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,691 @@
|
|||
package com.tangem.features.yield.supply.impl.main.model
|
||||
|
||||
import arrow.core.Option
|
||||
import arrow.core.left
|
||||
import arrow.core.none
|
||||
import arrow.core.right
|
||||
import arrow.core.some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.common.ui.earn.EarnBlockUM
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.StatusSource
|
||||
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.networks.single.SingleNetworkStatusFetcher
|
||||
import com.tangem.domain.stories.models.StoryContentIds
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.yield.supply.models.YieldMarketToken
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
|
||||
import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase
|
||||
import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusFlowUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetDustMinAmountUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyComponent
|
||||
import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles
|
||||
import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics
|
||||
import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader
|
||||
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyModelTest {
|
||||
|
||||
private val analytics: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
private val appRouter: AppRouter = mockk(relaxed = true)
|
||||
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
|
||||
private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
|
||||
private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk()
|
||||
private val getTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase = mockk()
|
||||
private val isAvailableUseCase: YieldSupplyIsAvailableUseCase = mockk()
|
||||
private val activateUseCase: YieldSupplyActivateUseCase = mockk()
|
||||
private val deactivateUseCase: YieldSupplyDeactivateUseCase = mockk()
|
||||
private val enterStatusUseCase: YieldSupplyEnterStatusUseCase = mockk()
|
||||
private val enterStatusFlowUseCase: YieldSupplyEnterStatusFlowUseCase = mockk()
|
||||
private val minAmountUseCase: YieldSupplyMinAmountUseCase = mockk()
|
||||
private val getDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase = mockk()
|
||||
private val isBoostPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk()
|
||||
private val getBoostedApyUseCase = GetBoostedApyUseCase()
|
||||
private val featureToggles: YieldSupplyFeatureToggles = mockk()
|
||||
private val boostStoryPreloader: YieldBoostStoryPreloader = mockk(relaxed = true)
|
||||
|
||||
private val userWalletId = UserWalletId("abcdef012345")
|
||||
private val userWallet: UserWallet = mockk(relaxed = true) { every { walletId } returns userWalletId }
|
||||
private val token: CryptoCurrency.Token = token()
|
||||
private val coin: CryptoCurrency.Coin = coin()
|
||||
private val accountStatusList: AccountStatusList = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkObject(CryptoCurrencyStatusOperations)
|
||||
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
|
||||
coEvery { isAvailableUseCase(any(), any()) } returns true
|
||||
every { getUserWalletUseCase(userWalletId) } returns userWallet.right()
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(accountStatusList)
|
||||
every { enterStatusFlowUseCase(any(), any()) } returns flowOf(null)
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns null.right()
|
||||
coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right()
|
||||
coEvery { getTokenStatusUseCase(any()) } returns marketToken(isActive = true).right()
|
||||
coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns false.right()
|
||||
every { featureToggles.isYieldPromoEnabled } returns false
|
||||
coEvery { activateUseCase(any(), any(), any()) } returns true.right()
|
||||
coEvery { deactivateUseCase(any(), any()) } returns true.right()
|
||||
coEvery { minAmountUseCase(any(), any()) } returns BigDecimal("5").right()
|
||||
every { getDustMinAmountUseCase(any(), any(), any()) } returns BigDecimal("0.1")
|
||||
stubStatus(status(isActive = false).some())
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkObject(CryptoCurrencyStatusOperations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN yield supply unavailable WHEN model created THEN stays initial and skips wallet load`() = runTest {
|
||||
// Arrange
|
||||
coEvery { isAvailableUseCase(any(), any()) } returns false
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
assertThat(model.uiState.value).isNull()
|
||||
verify(exactly = 0) { getUserWalletUseCase(any()) }
|
||||
coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet load fails WHEN model created THEN stays initial and skips status subscription`() = runTest {
|
||||
// Arrange
|
||||
every { getUserWalletUseCase(userWalletId) } returns mockk<GetUserWalletError>(relaxed = true).left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
verify(exactly = 0) { accountStatusListSupplier(any<UserWalletId>()) }
|
||||
coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token with active market WHEN status emitted THEN available state without boost`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value
|
||||
assertThat(legacy).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
assertThat((legacy as YieldSupplyUM.Available).isBoostAvailable).isFalse()
|
||||
assertThat(legacy.apy).isEqualTo("5")
|
||||
|
||||
val block = model.uiState.value
|
||||
assertThat(block).isInstanceOf(EarnBlockUM.Content::class.java)
|
||||
assertThat((block as EarnBlockUM.Content).backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.AccentSoft)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN promo enabled for token WHEN status emitted THEN boosted available promo`() = runTest {
|
||||
// Arrange
|
||||
every { featureToggles.isYieldPromoEnabled } returns true
|
||||
coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns true.right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value
|
||||
assertThat(legacy).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
assertThat((legacy as YieldSupplyUM.Available).isBoostAvailable).isTrue()
|
||||
assertThat(model.uiState.value).isInstanceOf(EarnBlockUM.Promo::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN app currency unavailable WHEN status emitted THEN falls back to default and still loads`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns SelectedAppCurrencyError.NoAppCurrencySelected.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token with inactive market WHEN status emitted THEN unavailable and no block`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getTokenStatusUseCase(any()) } returns marketToken(isActive = false).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Unavailable)
|
||||
assertThat(model.uiState.value).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token and token status fails WHEN status emitted THEN resets to initial`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getTokenStatusUseCase(any()) } returns Throwable("boom").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token allowed to spend WHEN status emitted THEN content without warning icon`() = runTest {
|
||||
// Arrange — supplied fully so the info-icon branch stays off
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value
|
||||
assertThat(legacy).isInstanceOf(YieldSupplyUM.Content::class.java)
|
||||
assertThat((legacy as YieldSupplyUM.Content).shouldShowWarningIcon).isFalse()
|
||||
assertThat(legacy.shouldShowInfoIcon).isFalse()
|
||||
verify(exactly = 0) { analytics.send(any<YieldSupplyAnalytics.NoticeApproveNeeded>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token not allowed to spend WHEN status emitted THEN warning icon and analytics sent`() = runTest {
|
||||
// Arrange
|
||||
stubStatus(status(isActive = true, isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content
|
||||
assertThat(legacy.shouldShowWarningIcon).isTrue()
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val approveEvent = events.filterIsInstance<YieldSupplyAnalytics.NoticeApproveNeeded>().single()
|
||||
assertThat(approveEvent.token).isEqualTo("TTK")
|
||||
assertThat(approveEvent.blockchain).isEqualTo("Ethereum")
|
||||
|
||||
val block = model.uiState.value as EarnBlockUM.Content
|
||||
assertThat(block.titleUM.iconUM?.tone).isEqualTo(EarnBlockUM.TitleUM.IconTone.Warning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token and token status fails WHEN status emitted THEN content with empty apy`() = runTest {
|
||||
// Arrange
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
coEvery { getTokenStatusUseCase(any()) } returns Throwable("boom").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content
|
||||
assertThat(legacy.apy).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token with not supplied amount WHEN status emitted THEN info icon shown`() = runTest {
|
||||
// Arrange — amount(10) > protocolBalance(1) so there is a not-supplied remainder above the dust limit
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content
|
||||
assertThat(legacy.shouldShowInfoIcon).isTrue()
|
||||
assertThat(legacy.shouldShowWarningIcon).isFalse()
|
||||
val block = model.uiState.value as EarnBlockUM.Content
|
||||
assertThat(block.titleUM.iconUM?.tone).isEqualTo(EarnBlockUM.TitleUM.IconTone.Info)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN not supplied amount below dust WHEN status emitted THEN info icon hidden`() = runTest {
|
||||
// Arrange — dust threshold far above the not-supplied fiat value
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some())
|
||||
every { getDustMinAmountUseCase(any(), any(), any()) } returns BigDecimal("1000")
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat((model.uiStateLegacy.value as YieldSupplyUM.Content).shouldShowInfoIcon).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN not supplied amount but min amount unavailable WHEN status emitted THEN info icon hidden`() = runTest {
|
||||
// Arrange — not-supplied remainder exists, but the min-amount lookup fails
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some())
|
||||
coEvery { minAmountUseCase(any(), any()) } returns Throwable("no min").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat((model.uiStateLegacy.value as YieldSupplyUM.Content).shouldShowInfoIcon).isFalse()
|
||||
verify(exactly = 0) { getDustMinAmountUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending enter status WHEN status emitted THEN processing enter`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Enter)
|
||||
assertThat(model.uiState.value).isInstanceOf(EarnBlockUM.Content::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN pending exit status WHEN status emitted THEN processing exit`() = runTest {
|
||||
// Arrange
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Exit(txIds = listOf("0x1")).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Exit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN processing state WHEN cached status emitted THEN keeps processing`() = runTest {
|
||||
// Arrange — first emission sets Processing.Enter, second (from cache) must be ignored
|
||||
val firstList: AccountStatusList = mockk()
|
||||
val secondList: AccountStatusList = mockk()
|
||||
val supplierFlow = MutableStateFlow(firstList)
|
||||
every { accountStatusListSupplier(userWalletId) } returns supplierFlow
|
||||
stubStatus(status(isActive = false, amount = BigDecimal.TEN).some(), firstList)
|
||||
stubStatus(
|
||||
option = status(isActive = false, amount = BigDecimal.ONE, networkSource = StatusSource.CACHE).some(),
|
||||
list = secondList,
|
||||
)
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns
|
||||
YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
supplierFlow.value = secondList
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Enter)
|
||||
coVerify(exactly = 1) { enterStatusUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN identical statuses emitted twice WHEN model created THEN downstream runs once`() = runTest {
|
||||
// Arrange — distinctUntilChanged must collapse equal emissions
|
||||
val firstList: AccountStatusList = mockk()
|
||||
val secondList: AccountStatusList = mockk()
|
||||
val sameStatus = status(isActive = false)
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(firstList, secondList)
|
||||
stubStatus(sameStatus.some(), firstList)
|
||||
stubStatus(sameStatus.some(), secondList)
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 1) { enterStatusUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN two distinct emissions WHEN model created THEN protocol status sent only on the first`() = runTest {
|
||||
// Arrange — first emission active, second inactive; the once-only compareAndSet must fire sendInfo on the first
|
||||
// only. If the guard were removed, the second (inactive) emission would call deactivate.
|
||||
val firstList: AccountStatusList = mockk()
|
||||
val secondList: AccountStatusList = mockk()
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(firstList, secondList)
|
||||
stubStatus(
|
||||
status(isActive = true, amount = BigDecimal.TEN, effectiveProtocolBalance = BigDecimal.TEN).some(),
|
||||
firstList,
|
||||
)
|
||||
stubStatus(
|
||||
status(isActive = false, amount = BigDecimal.ONE).some(),
|
||||
secondList,
|
||||
)
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert — activate fired once (first emission); the guard suppressed the second, so deactivate never ran
|
||||
coVerify(exactly = 1) { activateUseCase(userWalletId, token, SOURCE_ADDRESS) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN cached status while not processing WHEN status emitted THEN state still advances`() = runTest {
|
||||
// Arrange — the cache guard must short-circuit ONLY while Processing
|
||||
stubStatus(status(isActive = false, networkSource = StatusSource.CACHE).some())
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin currency WHEN status emitted THEN token-only logic is skipped`() = runTest {
|
||||
// Arrange — every token-specific step guards on CryptoCurrency.Token
|
||||
stubStatus(status(currency = coin, isActive = false).some())
|
||||
|
||||
// Act
|
||||
val model = createModel(currency = coin)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial)
|
||||
coVerify(exactly = 0) { getTokenStatusUseCase(any()) }
|
||||
coVerify(exactly = 0) { activateUseCase(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active status on first emission WHEN model created THEN activates protocol`() = runTest {
|
||||
// Arrange
|
||||
stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some())
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify { activateUseCase(userWalletId, token, SOURCE_ADDRESS) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive status on first emission WHEN model created THEN deactivates protocol`() = runTest {
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify { deactivateUseCase(token, SOURCE_ADDRESS) }
|
||||
coVerify(exactly = 0) { activateUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN missing network address WHEN status emitted THEN protocol status not sent`() = runTest {
|
||||
// Arrange — a Loading value carries no network address, so the side-effect must short-circuit
|
||||
stubStatus(CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading).some())
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) { activateUseCase(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { deactivateUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN latest status loaded WHEN onStartEarningClick THEN pushes yield entry route`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
val routeSlot = slot<AppRoute>()
|
||||
|
||||
// Act
|
||||
model.onStartEarningClick()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.push(capture(routeSlot), any()) }
|
||||
val route = routeSlot.captured as AppRoute.YieldSupplyEntry
|
||||
assertThat(route.userWalletId).isEqualTo(userWalletId)
|
||||
assertThat(route.cryptoCurrency).isEqualTo(token)
|
||||
assertThat(route.apy).isEqualTo("5")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN processing state WHEN onStartEarningClick THEN pushes route with empty apy`() = runTest {
|
||||
// Arrange — Processing state has no apy field, so the route apy collapses to empty
|
||||
coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
val routeSlot = slot<AppRoute>()
|
||||
|
||||
// Act
|
||||
model.onStartEarningClick()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.push(capture(routeSlot), any()) }
|
||||
assertThat((routeSlot.captured as AppRoute.YieldSupplyEntry).apy).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no latest status WHEN onActiveClick THEN does not navigate`() = runTest {
|
||||
// Arrange — currency status never resolves, so latestCryptoCurrencyStatus stays null
|
||||
stubStatus(none())
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onActiveClick()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 0) { appRouter.push(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN latest status loaded WHEN onLearnMoreClick THEN pushes stories route`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
val routeSlot = slot<AppRoute>()
|
||||
|
||||
// Act
|
||||
model.onLearnMoreClick()
|
||||
|
||||
// Assert
|
||||
verify { appRouter.push(capture(routeSlot), any()) }
|
||||
val route = routeSlot.captured as AppRoute.Stories
|
||||
assertThat(route.storyId).isEqualTo(StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id)
|
||||
assertThat(route.screenSource).isEqualTo("TokenDetails")
|
||||
assertThat(route.nextScreen).isInstanceOf(AppRoute.YieldSupplyEntry::class.java)
|
||||
}
|
||||
|
||||
private fun stubStatus(option: Option<CryptoCurrencyStatus>, list: AccountStatusList = accountStatusList) {
|
||||
every {
|
||||
with(CryptoCurrencyStatusOperations) { list.getCryptoCurrencyStatus(any<CryptoCurrency>()) }
|
||||
} returns option
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(currency: CryptoCurrency = token): YieldSupplyModel = YieldSupplyModel(
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyComponent.Params(userWalletId = userWalletId, cryptoCurrency = currency),
|
||||
),
|
||||
dispatchers = createDispatchers(),
|
||||
analyticsEventsHandler = analytics,
|
||||
appRouter = appRouter,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
singleAccountStatusListSupplier = accountStatusListSupplier,
|
||||
singleNetworkStatusFetcher = singleNetworkStatusFetcher,
|
||||
yieldSupplyGetTokenStatusUseCase = getTokenStatusUseCase,
|
||||
yieldSupplyIsAvailableUseCase = isAvailableUseCase,
|
||||
yieldSupplyActivateUseCase = activateUseCase,
|
||||
yieldSupplyDeactivateUseCase = deactivateUseCase,
|
||||
yieldSupplyEnterStatusUseCase = enterStatusUseCase,
|
||||
yieldSupplyEnterStatusFlowUseCase = enterStatusFlowUseCase,
|
||||
yieldSupplyMinAmountUseCase = minAmountUseCase,
|
||||
yieldSupplyGetDustMinAmountUseCase = getDustMinAmountUseCase,
|
||||
isYieldBoostPromoEnabledForTokenUseCase = isBoostPromoEnabledUseCase,
|
||||
getBoostedApyUseCase = getBoostedApyUseCase,
|
||||
yieldSupplyFeatureToggles = featureToggles,
|
||||
boostStoryPreloader = boostStoryPreloader,
|
||||
)
|
||||
|
||||
private fun TestScope.createDispatchers(): TestingCoroutineDispatcherProvider {
|
||||
val dispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = dispatcher,
|
||||
mainImmediate = dispatcher,
|
||||
io = dispatcher,
|
||||
default = dispatcher,
|
||||
single = dispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
private fun status(
|
||||
currency: CryptoCurrency = token,
|
||||
isActive: Boolean = false,
|
||||
isAllowedToSpend: Boolean = true,
|
||||
amount: BigDecimal = BigDecimal.TEN,
|
||||
effectiveProtocolBalance: BigDecimal? = BigDecimal.ONE,
|
||||
fiatRate: BigDecimal? = BigDecimal.ONE,
|
||||
networkSource: StatusSource = StatusSource.ACTUAL,
|
||||
address: String = SOURCE_ADDRESS,
|
||||
): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = amount,
|
||||
fiatAmount = amount,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = isActive,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = isAllowedToSpend,
|
||||
effectiveProtocolBalance = effectiveProtocolBalance,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(value = address, type = NetworkAddress.Address.Type.Primary),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(networkSource = networkSource),
|
||||
),
|
||||
)
|
||||
|
||||
private fun marketToken(isActive: Boolean): YieldMarketToken = YieldMarketToken(
|
||||
tokenAddress = "0xToken",
|
||||
chainId = 1,
|
||||
apy = BigDecimal("5"),
|
||||
isActive = isActive,
|
||||
maxFeeNative = BigDecimal.ZERO,
|
||||
maxFeeUSD = BigDecimal.ZERO,
|
||||
backendId = "ethereum",
|
||||
)
|
||||
|
||||
private fun token(): CryptoCurrency.Token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
|
||||
private fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_COIN",
|
||||
symbol = "ETH",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
private fun network(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SOURCE_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package com.tangem.features.yield.supply.impl.main.model.transformers
|
||||
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.extensions.annotatedReference
|
||||
import com.tangem.core.ui.extensions.combinedReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.yield.supply.models.YieldMarketToken
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyTokenStatusSuccessTransformerTest {
|
||||
|
||||
private var startEarningClicked = false
|
||||
private var learnMoreClicked = false
|
||||
|
||||
@Test
|
||||
fun `GIVEN inactive token WHEN transform THEN Unavailable`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(tokenStatus = marketToken(isActive = false))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(YieldSupplyUM.Initial)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(YieldSupplyUM.Unavailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token without boost WHEN transform THEN Available with plain apy text`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(tokenStatus = marketToken(isActive = true, apy = BigDecimal("5.5")))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(YieldSupplyUM.Initial)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
val available = result as YieldSupplyUM.Available
|
||||
assertThat(available.isBoostAvailable).isFalse()
|
||||
assertThat(available.apy).isEqualTo("5.5")
|
||||
assertThat(available.title).isEqualTo(
|
||||
resourceReference(R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title),
|
||||
)
|
||||
assertThat(available.apyText).isEqualTo(
|
||||
combinedReference(
|
||||
resourceReference(R.string.yield_module_token_details_earn_notification_apy),
|
||||
stringReference(" 5.5%"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token with boost WHEN transform THEN Available with boosted apy text and title`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(
|
||||
tokenStatus = marketToken(isActive = true, apy = BigDecimal("5.5")),
|
||||
boostedApy = BigDecimal("16.5"),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(YieldSupplyUM.Initial)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isInstanceOf(YieldSupplyUM.Available::class.java)
|
||||
val available = result as YieldSupplyUM.Available
|
||||
assertThat(available.isBoostAvailable).isTrue()
|
||||
assertThat(available.title).isEqualTo(resourceReference(R.string.yield_apy_boost_banner_title))
|
||||
assertThat(available.apyText).isEqualTo(
|
||||
annotatedReference(
|
||||
buildAnnotatedString {
|
||||
append("APY ")
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
append("5.5%")
|
||||
}
|
||||
append(" x3 → 16.5%")
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN active token WHEN clicks delegated THEN original callbacks fire`() {
|
||||
// Arrange
|
||||
val transformer = createTransformer(tokenStatus = marketToken(isActive = true))
|
||||
|
||||
// Act
|
||||
val available = transformer.transform(YieldSupplyUM.Initial) as YieldSupplyUM.Available
|
||||
available.onClick()
|
||||
available.onLearnMoreClick()
|
||||
|
||||
// Assert
|
||||
assertThat(startEarningClicked).isTrue()
|
||||
assertThat(learnMoreClicked).isTrue()
|
||||
}
|
||||
|
||||
private fun createTransformer(
|
||||
tokenStatus: YieldMarketToken,
|
||||
boostedApy: BigDecimal? = null,
|
||||
): YieldSupplyTokenStatusSuccessTransformer = YieldSupplyTokenStatusSuccessTransformer(
|
||||
tokenStatus = tokenStatus,
|
||||
onStartEarningClick = { startEarningClicked = true },
|
||||
onLearnMoreClick = { learnMoreClicked = true },
|
||||
boostedApy = boostedApy,
|
||||
)
|
||||
|
||||
private fun marketToken(isActive: Boolean, apy: BigDecimal = BigDecimal("5.5")): YieldMarketToken =
|
||||
YieldMarketToken(
|
||||
tokenAddress = "0xToken",
|
||||
chainId = 1,
|
||||
apy = apy,
|
||||
isActive = isActive,
|
||||
maxFeeNative = BigDecimal.ZERO,
|
||||
maxFeeUSD = BigDecimal.ZERO,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents
|
||||
|
||||
import arrow.core.right
|
||||
import com.tangem.blockchain.common.Amount
|
||||
import com.tangem.blockchain.common.AmountType
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.navigation.url.UrlOpener
|
||||
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
||||
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
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.transaction.usecase.GetFeeUseCase
|
||||
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
|
||||
import com.tangem.domain.yield.supply.YieldSupplyRepository
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyPendingTracker
|
||||
import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsUpdateTrigger
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Shared fixtures, mocks and builders for the Yield Supply transactional model tests
|
||||
* (Approve / StopEarning / StartEarning). Subclasses declare their own unique mocks and build
|
||||
* the concrete model via the base mocks; tests read [uiState] synchronously thanks to the
|
||||
* Unconfined [TestingCoroutineDispatcherProvider].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal abstract class YieldSupplyActionModelTestBase {
|
||||
|
||||
protected val analytics: AnalyticsEventHandler = mockk(relaxed = true)
|
||||
protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk()
|
||||
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk()
|
||||
protected val sendTransactionUseCase: SendTransactionUseCase = mockk()
|
||||
protected val getFeeUseCase: GetFeeUseCase = mockk()
|
||||
protected val urlOpener: UrlOpener = mockk(relaxed = true)
|
||||
protected val notificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger = mockk(relaxed = true)
|
||||
protected val alertFactory: YieldSupplyAlertFactory = mockk(relaxed = true)
|
||||
protected val pendingTracker: YieldSupplyPendingTracker = mockk(relaxed = true)
|
||||
protected val yieldSupplyRepository: YieldSupplyRepository = mockk(relaxed = true)
|
||||
protected val appsFlyerStore: AppsFlyerStore = mockk(relaxed = true)
|
||||
|
||||
protected val userWalletId = UserWalletId("abcdef012345")
|
||||
protected val userWallet: UserWallet = mockk(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
protected val token: CryptoCurrency.Token = token()
|
||||
protected val coin: CryptoCurrency.Coin = coin()
|
||||
protected val cryptoCurrencyStatus: CryptoCurrencyStatus = statusOf(token)
|
||||
protected val cryptoCurrencyStatusFlow = MutableStateFlow(cryptoCurrencyStatus)
|
||||
|
||||
@BeforeEach
|
||||
fun baseSetUp() {
|
||||
coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right()
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns MutableStateFlow(false)
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns cryptoCurrencyStatus.right()
|
||||
}
|
||||
|
||||
/** A [StandardTestDispatcher] for every role so `advanceUntilIdle()` drives the model's coroutines. */
|
||||
protected fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
main = testDispatcher,
|
||||
mainImmediate = testDispatcher,
|
||||
io = testDispatcher,
|
||||
default = testDispatcher,
|
||||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
/** Network fee is paid in the native coin (token amounts are rejected by `increaseGasLimitBy`). */
|
||||
protected fun coinAmount(value: BigDecimal): Amount =
|
||||
Amount(currencySymbol = "ETH", value = value, decimals = 18, type = AmountType.Coin)
|
||||
|
||||
protected fun ethFee(value: BigDecimal = BigDecimal("0.001")): Fee.Ethereum.EIP1559 = Fee.Ethereum.EIP1559(
|
||||
maxFeePerGas = BigInteger.valueOf(1_000_000_000L),
|
||||
priorityFee = BigInteger.ONE,
|
||||
gasLimit = BigInteger.valueOf(21_000),
|
||||
amount = coinAmount(value),
|
||||
)
|
||||
|
||||
protected fun transactionFee(value: BigDecimal = BigDecimal("0.001")): TransactionFee.Single =
|
||||
TransactionFee.Single(normal = ethFee(value))
|
||||
|
||||
protected fun uncompiledTx(fee: Fee = ethFee()): TransactionData.Uncompiled = TransactionData.Uncompiled(
|
||||
fee = fee,
|
||||
amount = coinAmount(BigDecimal.ONE),
|
||||
contractAddress = null,
|
||||
sourceAddress = SOURCE_ADDRESS,
|
||||
destinationAddress = DESTINATION_ADDRESS,
|
||||
extras = null,
|
||||
)
|
||||
|
||||
protected fun statusOf(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = currency,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.TEN,
|
||||
fiatAmount = BigDecimal.TEN,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = YieldSupplyStatus(
|
||||
isActive = true,
|
||||
isInitialized = true,
|
||||
isAllowedToSpend = true,
|
||||
effectiveProtocolBalance = BigDecimal.ONE,
|
||||
),
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = SOURCE_ADDRESS,
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
protected fun token(): CryptoCurrency.Token = CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
|
||||
protected fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network(),
|
||||
name = "TEST_COIN",
|
||||
symbol = "ETH",
|
||||
decimals = 18,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
)
|
||||
|
||||
protected fun network(): Network {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
return Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
}
|
||||
|
||||
protected companion object {
|
||||
const val SOURCE_ADDRESS = "0x1111111111111111111111111111111111111111"
|
||||
const val DESTINATION_ADDRESS = "0x2222222222222222222222222222222222222222"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.approve.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.TangemBlogUrlBuilder
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetContractAddressUseCase
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyApproveComponent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyApproveModelTest : YieldSupplyActionModelTestBase() {
|
||||
|
||||
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk()
|
||||
private val getContractAddressUseCase: YieldSupplyGetContractAddressUseCase = mockk()
|
||||
private val callback: YieldSupplyApproveComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
coEvery { getContractAddressUseCase(any(), any()) } returns "0xSpender".right()
|
||||
coEvery {
|
||||
createApprovalTransactionUseCase(any(), any(), any(), any(), any())
|
||||
} returns uncompiledTx().right()
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns transactionFee().right()
|
||||
coEvery { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } returns "0xhash".right()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java)
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
coVerify { notificationsUpdateTrigger.triggerUpdate(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN get fee fails WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-token currency WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Act
|
||||
val model = createModel(statusFlow = MutableStateFlow(statusOf(coin)))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN contract address missing WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getContractAddressUseCase(any(), any()) } returns (null as String?).right()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick THEN sends transaction tracks pending and notifies sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify { pendingTracker.addPending(userWalletId, any(), any()) }
|
||||
verify { callback.onTransactionSent() }
|
||||
|
||||
// Token fee asset (default fee currency is the token itself)
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("TTK")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Token.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin fee currency WHEN onClick succeeds THEN transaction sent analytics carries coin fee asset`() = runTest {
|
||||
// Arrange — network fee paid in the native coin, not the token
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns statusOf(coin).right()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("ETH")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Coin.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee not loaded WHEN onClick THEN does not send transaction`() = runTest {
|
||||
// Arrange — fee load fails so the fee state is Error; onClick reports progress then early-returns
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify(exactly = 0) { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest {
|
||||
// Arrange
|
||||
val hasErrorFlow = MutableStateFlow(false)
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
|
||||
// Act
|
||||
hasErrorFlow.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and stops progress`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
sendTransactionUseCase(txData = any(), userWallet = any(), network = any())
|
||||
} returns SendTransactionError.UnknownError().left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isTransactionSending).isFalse()
|
||||
verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) }
|
||||
verify { callback.onTransactionProgress(false) }
|
||||
verify(exactly = 0) { callback.onTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onReadMoreClick THEN opens url`() = runTest {
|
||||
// Arrange — TangemBlogUrlBuilder.build is a real suspend object; stub it to isolate the model's intent
|
||||
mockkObject(TangemBlogUrlBuilder)
|
||||
try {
|
||||
coEvery { TangemBlogUrlBuilder.build(any()) } returns BLOG_URL
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onReadMoreClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { urlOpener.openUrl(BLOG_URL) }
|
||||
} finally {
|
||||
unmockkObject(TangemBlogUrlBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(
|
||||
statusFlow: StateFlow<CryptoCurrencyStatus> = cryptoCurrencyStatusFlow,
|
||||
): YieldSupplyApproveModel = YieldSupplyApproveModel(
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyApproveComponent.Params(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencyStatusFlow = statusFlow,
|
||||
callback = callback,
|
||||
),
|
||||
),
|
||||
analyticsEventHandler = analytics,
|
||||
urlOpener = urlOpener,
|
||||
yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger,
|
||||
createApprovalTransactionUseCase = createApprovalTransactionUseCase,
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
|
||||
yieldSupplyGetContractAddressUseCase = getContractAddressUseCase,
|
||||
yieldSupplyPendingTracker = pendingTracker,
|
||||
yieldSupplyAlertFactory = alertFactory,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val BLOG_URL = "https://tangem.com/blog"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.startearning.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.none
|
||||
import arrow.core.right
|
||||
import arrow.core.some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.domain.account.models.AccountStatusList
|
||||
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
|
||||
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.wallets.models.errors.GetUserWalletError
|
||||
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
|
||||
import com.tangem.domain.yield.supply.YieldSupplyError
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyFee
|
||||
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetCurrentFeeUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetMaxFeeUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningComponent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyStartEarningModelTest : YieldSupplyActionModelTestBase() {
|
||||
|
||||
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
|
||||
private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk()
|
||||
private val startEarningUseCase: YieldSupplyStartEarningUseCase = mockk()
|
||||
private val estimateEnterFeeUseCase: YieldSupplyEstimateEnterFeeUseCase = mockk()
|
||||
private val activateUseCase: YieldSupplyActivateUseCase = mockk()
|
||||
private val minAmountUseCase: YieldSupplyMinAmountUseCase = mockk()
|
||||
private val getMaxFeeUseCase: YieldSupplyGetMaxFeeUseCase = mockk()
|
||||
private val getCurrentFeeUseCase: YieldSupplyGetCurrentFeeUseCase = mockk()
|
||||
|
||||
private val accountStatusList: AccountStatusList = mockk()
|
||||
private val callback: YieldSupplyStartEarningComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
mockkObject(CryptoCurrencyStatusOperations)
|
||||
every { getUserWalletUseCase(userWalletId) } returns userWallet.right()
|
||||
every { accountStatusListSupplier(userWalletId) } returns flowOf(accountStatusList)
|
||||
stubCurrencyStatusLookup(cryptoCurrencyStatus.some())
|
||||
coEvery { minAmountUseCase(any(), any()) } returns BigDecimal("5").right()
|
||||
coEvery { getMaxFeeUseCase(any(), any()) } returns maxFee().right()
|
||||
coEvery { getCurrentFeeUseCase(any(), any()) } returns YieldSupplyFee(BigDecimal("0.001")).right()
|
||||
coEvery { startEarningUseCase(any(), any(), any()) } returns listOf(uncompiledTx()).right()
|
||||
coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns listOf(uncompiledTx()).right()
|
||||
coEvery {
|
||||
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
|
||||
} returns listOf("0xhash").right()
|
||||
coEvery { activateUseCase(any(), any(), any()) } returns true.right()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkObject(CryptoCurrencyStatusOperations)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java)
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
coVerify { notificationsUpdateTrigger.triggerUpdate(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN estimate fee fails WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN max fee unavailable WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getMaxFeeUseCase(any(), any()) } returns Throwable("no max fee").left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
coVerify(exactly = 0) { estimateEnterFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet unavailable WHEN model created THEN shows generic error`() = runTest {
|
||||
// Arrange
|
||||
every { getUserWalletUseCase(userWalletId) } returns mockk<GetUserWalletError>(relaxed = true).left()
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { alertFactory.getGenericErrorState(any(), any()) }
|
||||
coVerify(exactly = 0) { getMaxFeeUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status not found WHEN model created THEN shows generic error`() = runTest {
|
||||
// Arrange
|
||||
stubCurrencyStatusLookup(none())
|
||||
|
||||
// Act
|
||||
createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { alertFactory.getGenericErrorState(any(), any()) }
|
||||
coVerify(exactly = 0) { getMaxFeeUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick THEN sends activates tracks pending and notifies sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, any(), any()) }
|
||||
coVerify { activateUseCase(userWalletId, any(), any()) }
|
||||
coVerify { pendingTracker.addPending(userWalletId, any(), any()) }
|
||||
verify { callback.onTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and not sent`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
|
||||
} returns SendTransactionError.UnknownError().left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isTransactionSending).isFalse()
|
||||
verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) }
|
||||
verify(exactly = 0) { callback.onTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee not loaded WHEN onClick THEN does not send transactions`() = runTest {
|
||||
// Arrange — estimate fee fails so the fee state is Error; onClick must early-return before sending
|
||||
coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
coVerify(exactly = 0) {
|
||||
sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest {
|
||||
// Arrange
|
||||
val hasErrorFlow = MutableStateFlow(false)
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
|
||||
// Act
|
||||
hasErrorFlow.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
private fun stubCurrencyStatusLookup(result: arrow.core.Option<com.tangem.domain.models.currency.CryptoCurrencyStatus>) {
|
||||
every {
|
||||
with(CryptoCurrencyStatusOperations) {
|
||||
accountStatusList.getCryptoCurrencyStatus(any<CryptoCurrency>())
|
||||
}
|
||||
} returns result
|
||||
}
|
||||
|
||||
private fun maxFee(): YieldSupplyMaxFee = YieldSupplyMaxFee(
|
||||
nativeMaxFee = BigDecimal("0.01"),
|
||||
tokenMaxFee = BigDecimal("2"),
|
||||
fiatMaxFee = BigDecimal("4"),
|
||||
)
|
||||
|
||||
private fun TestScope.createModel(): YieldSupplyStartEarningModel = YieldSupplyStartEarningModel(
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyStartEarningComponent.Params(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrency = token,
|
||||
yieldSupplyActionUM = actionUM(),
|
||||
callback = callback,
|
||||
),
|
||||
),
|
||||
analytics = analytics,
|
||||
getUserWalletUseCase = getUserWalletUseCase,
|
||||
singleAccountStatusListSupplier = accountStatusListSupplier,
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
yieldSupplyStartEarningUseCase = startEarningUseCase,
|
||||
yieldSupplyEstimateEnterFeeUseCase = estimateEnterFeeUseCase,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger,
|
||||
yieldSupplyAlertFactory = alertFactory,
|
||||
yieldSupplyActivateUseCase = activateUseCase,
|
||||
yieldSupplyMinAmountUseCase = minAmountUseCase,
|
||||
yieldSupplyGetMaxFeeUseCase = getMaxFeeUseCase,
|
||||
yieldSupplyGetCurrentFeeUseCase = getCurrentFeeUseCase,
|
||||
yieldSupplyRepository = yieldSupplyRepository,
|
||||
yieldSupplyPendingTracker = pendingTracker,
|
||||
appsFlyerStore = appsFlyerStore,
|
||||
)
|
||||
|
||||
private fun actionUM(): YieldSupplyActionUM = YieldSupplyActionUM(
|
||||
title = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
footer = stringReference(""),
|
||||
footerLink = stringReference(""),
|
||||
currencyIconState = mockk<CurrencyIconState>(relaxed = true),
|
||||
yieldSupplyFeeUM = YieldSupplyFeeUM.Loading,
|
||||
isPrimaryButtonEnabled = false,
|
||||
isTransactionSending = false,
|
||||
isHoldToConfirmEnabled = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.startearning.model.transformers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
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.yield.supply.models.YieldSupplyMaxFee
|
||||
import com.tangem.features.yield.supply.impl.R
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyStartEarningFeeContentTransformerTest {
|
||||
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status loading WHEN transform THEN fee Loading and button flag preserved`() {
|
||||
// Arrange — prevState button flag is false; the Loading branch must not flip it
|
||||
val transformer = createTransformer(currencyStatus = loadingStatus())
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
assertThat(result.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status with rates WHEN transform THEN fee Content with every fiat field computed`() {
|
||||
// Arrange — tokenFiatRate 1, feeFiatRate 2; feeValue 0.5, estimatedToken 0.4, minAmount 3, maxFee 2 token / 4 fiat
|
||||
val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert — whole Content compared field-by-field (no fields touched on isPrimaryButtonEnabled)
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
expectedContent(tokenFiatRate = BigDecimal("1"), feeFiatRate = BigDecimal("2")),
|
||||
)
|
||||
assertThat(result.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status but missing rates WHEN transform THEN fiat fields collapse to placeholders`() {
|
||||
// Arrange — negative: both token and fee fiat rates unavailable
|
||||
val transformer = createTransformer(currencyStatus = customStatus(null), feeFiatRate = null)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert — fiat-derived fields become the placeholder; crypto fields and the max fiat fee stay populated
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
expectedContent(tokenFiatRate = null, feeFiatRate = null),
|
||||
)
|
||||
}
|
||||
|
||||
private fun expectedContent(tokenFiatRate: BigDecimal?, feeFiatRate: BigDecimal?): YieldSupplyFeeUM.Content {
|
||||
val feeFiatText = fiatText(feeFiatRate?.let(FEE_VALUE::multiply))
|
||||
val estimatedFiatText = fiatText(tokenFiatRate?.let(ESTIMATED_TOKEN::multiply))
|
||||
val estimatedCryptoText = cryptoText(ESTIMATED_TOKEN)
|
||||
val maxFiatText = fiatText(MAX_FIAT_FEE)
|
||||
val maxCryptoText = cryptoText(MAX_TOKEN_FEE)
|
||||
val minFiatText = fiatText(tokenFiatRate?.let(MIN_AMOUNT::multiply))
|
||||
val minCryptoText = cryptoText(MIN_AMOUNT)
|
||||
return YieldSupplyFeeUM.Content(
|
||||
transactionDataList = persistentListOf(),
|
||||
feeFiatValue = stringReference(feeFiatText),
|
||||
estimatedFiatValue = stringReference(estimatedFiatText),
|
||||
maxNetworkFeeFiatValue = stringReference(maxFiatText),
|
||||
minTopUpFiatValue = stringReference(minFiatText),
|
||||
feeNoteValue = resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_fee_note,
|
||||
formatArgs = wrappedList(estimatedFiatText, estimatedCryptoText, maxFiatText, maxCryptoText),
|
||||
),
|
||||
minFeeNoteValue = resourceReference(
|
||||
id = R.string.yield_module_fee_policy_sheet_min_amount_note,
|
||||
formatArgs = wrappedList(minFiatText, minCryptoText),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) }
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun createTransformer(
|
||||
currencyStatus: CryptoCurrencyStatus,
|
||||
feeFiatRate: BigDecimal? = BigDecimal("1"),
|
||||
): YieldSupplyStartEarningFeeContentTransformer = YieldSupplyStartEarningFeeContentTransformer(
|
||||
cryptoCurrencyStatus = currencyStatus,
|
||||
feeCryptoCurrencyStatus = customStatus(feeFiatRate),
|
||||
appCurrency = appCurrency,
|
||||
updatedTransactionList = emptyList(),
|
||||
feeValue = FEE_VALUE,
|
||||
estimatedFeeValueInTokenCurrency = ESTIMATED_TOKEN,
|
||||
maxNetworkFee = YieldSupplyMaxFee(
|
||||
nativeMaxFee = BigDecimal("0.01"),
|
||||
tokenMaxFee = MAX_TOKEN_FEE,
|
||||
fiatMaxFee = MAX_FIAT_FEE,
|
||||
),
|
||||
minAmount = MIN_AMOUNT,
|
||||
)
|
||||
|
||||
private fun customStatus(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun loadingStatus(): CryptoCurrencyStatus =
|
||||
CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading)
|
||||
|
||||
private fun prevState(): YieldSupplyActionUM = YieldSupplyActionUM(
|
||||
title = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
footer = stringReference(""),
|
||||
footerLink = stringReference(""),
|
||||
currencyIconState = mockk<CurrencyIconState>(relaxed = true),
|
||||
yieldSupplyFeeUM = YieldSupplyFeeUM.Error,
|
||||
isPrimaryButtonEnabled = false,
|
||||
isTransactionSending = false,
|
||||
isHoldToConfirmEnabled = false,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val FEE_VALUE: BigDecimal = BigDecimal("0.5")
|
||||
val ESTIMATED_TOKEN: BigDecimal = BigDecimal("0.4")
|
||||
val MIN_AMOUNT: BigDecimal = BigDecimal("3")
|
||||
val MAX_TOKEN_FEE: BigDecimal = BigDecimal("2")
|
||||
val MAX_FIAT_FEE: BigDecimal = BigDecimal("4")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.TangemBlogUrlBuilder
|
||||
import com.tangem.core.analytics.models.AnalyticsEvent
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.analytics.models.Basic
|
||||
import com.tangem.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.transaction.error.GetFeeError
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.yield.supply.YieldSupplyError
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase
|
||||
import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase
|
||||
import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class YieldSupplyStopEarningModelTest : YieldSupplyActionModelTestBase() {
|
||||
|
||||
private val stopEarningUseCase: YieldSupplyStopEarningUseCase = mockk()
|
||||
private val deactivateUseCase: YieldSupplyDeactivateUseCase = mockk()
|
||||
private val callback: YieldSupplyStopEarningComponent.ModelCallback = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
coEvery { stopEarningUseCase(any(), any(), any()) } returns uncompiledTx().right()
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns transactionFee().right()
|
||||
coEvery { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } returns "0xhash".right()
|
||||
coEvery { deactivateUseCase(any(), any()) } returns true.right()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest {
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java)
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
coVerify { notificationsUpdateTrigger.triggerUpdate(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN get fee fails WHEN model created THEN fee error state`() = runTest {
|
||||
// Arrange
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-token currency WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Act
|
||||
val model = createModel(statusFlow = MutableStateFlow(statusOf(coin)))
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN stop earning use case fails WHEN model created THEN fee not loaded`() = runTest {
|
||||
// Arrange
|
||||
coEvery { stopEarningUseCase(any(), any(), any()) } returns YieldSupplyError.DataError(Throwable()).left()
|
||||
|
||||
// Act
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick THEN sends deactivates tracks pending and notifies sent`() = runTest {
|
||||
// Arrange
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, any(), any()) }
|
||||
coVerify { deactivateUseCase(any(), any()) }
|
||||
coVerify { pendingTracker.addPending(userWalletId, any(), any()) }
|
||||
verify { callback.onStopEarningTransactionSent() }
|
||||
|
||||
// Token fee asset (default fee currency is the token itself)
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("TTK")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Token.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coin fee currency WHEN onClick succeeds THEN transaction sent analytics carries coin fee asset`() = runTest {
|
||||
// Arrange — network fee paid in the native coin, not the token
|
||||
coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns statusOf(coin).right()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
val events = mutableListOf<AnalyticsEvent>()
|
||||
verify { analytics.send(capture(events)) }
|
||||
val sent = events.filterIsInstance<Basic.TransactionSent>().single()
|
||||
assertThat(sent.params["Fee Token"]).isEqualTo("ETH")
|
||||
assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Coin.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee not loaded WHEN onClick THEN does not send transaction`() = runTest {
|
||||
// Arrange — fee load fails so the fee state is Error; onClick reports progress then early-returns
|
||||
coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { callback.onTransactionProgress(true) }
|
||||
coVerify(exactly = 0) { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest {
|
||||
// Arrange
|
||||
val hasErrorFlow = MutableStateFlow(false)
|
||||
every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue()
|
||||
|
||||
// Act
|
||||
hasErrorFlow.value = true
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and stops progress`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
sendTransactionUseCase(txData = any(), userWallet = any(), network = any())
|
||||
} returns SendTransactionError.UnknownError().left()
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(model.uiState.value.isTransactionSending).isFalse()
|
||||
verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) }
|
||||
verify { callback.onTransactionProgress(false) }
|
||||
verify(exactly = 0) { callback.onStopEarningTransactionSent() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN onReadMoreClick THEN opens url`() = runTest {
|
||||
// Arrange
|
||||
mockkObject(TangemBlogUrlBuilder)
|
||||
try {
|
||||
coEvery { TangemBlogUrlBuilder.build(any()) } returns BLOG_URL
|
||||
val model = createModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.onReadMoreClick()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { urlOpener.openUrl(BLOG_URL) }
|
||||
} finally {
|
||||
unmockkObject(TangemBlogUrlBuilder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createModel(
|
||||
statusFlow: StateFlow<CryptoCurrencyStatus> = cryptoCurrencyStatusFlow,
|
||||
): YieldSupplyStopEarningModel = YieldSupplyStopEarningModel(
|
||||
dispatchers = createTestingCoroutineDispatcherProvider(),
|
||||
paramsContainer = MutableParamsContainer(
|
||||
YieldSupplyStopEarningComponent.Params(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencyStatusFlow = statusFlow,
|
||||
callback = callback,
|
||||
),
|
||||
),
|
||||
analytics = analytics,
|
||||
getFeeUseCase = getFeeUseCase,
|
||||
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
|
||||
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
yieldSupplyStopEarningUseCase = stopEarningUseCase,
|
||||
urlOpener = urlOpener,
|
||||
yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger,
|
||||
yieldSupplyAlertFactory = alertFactory,
|
||||
yieldSupplyDeactivateUseCase = deactivateUseCase,
|
||||
yieldSupplyRepository = yieldSupplyRepository,
|
||||
yieldSupplyPendingTracker = pendingTracker,
|
||||
appsFlyerStore = appsFlyerStore,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val BLOG_URL = "https://tangem.com/blog"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.transformer
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
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.features.yield.supply.impl.common.entity.YieldSupplyActionUM
|
||||
import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class YieldSupplyStopEarningFeeContentTransformerTest {
|
||||
|
||||
private val token = createToken()
|
||||
private val appCurrency = AppCurrency.Default
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency status loading WHEN transform THEN fee Loading and button flag preserved`() {
|
||||
// Arrange — prevState button flag is false; the Loading branch must not flip it
|
||||
val transformer = createTransformer(currencyStatus = loadingStatus(), feeFiatRate = BigDecimal("1"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading)
|
||||
assertThat(result.isPrimaryButtonEnabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status with fee rate WHEN transform THEN only fiat fee set and the rest EMPTY`() {
|
||||
// Arrange — feeValue 0.5, feeFiatRate 2 → fiat fee = 1.0; all other fee fields are intentionally EMPTY
|
||||
val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = BigDecimal("2"))
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isPrimaryButtonEnabled).isTrue()
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
YieldSupplyFeeUM.Content(
|
||||
transactionDataList = persistentListOf(),
|
||||
feeFiatValue = stringReference(fiatText(BigDecimal("0.5").multiply(BigDecimal("2")))),
|
||||
estimatedFiatValue = TextReference.EMPTY,
|
||||
maxNetworkFeeFiatValue = TextReference.EMPTY,
|
||||
minTopUpFiatValue = TextReference.EMPTY,
|
||||
feeNoteValue = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded status but missing fee rate WHEN transform THEN fiat fee is the placeholder`() {
|
||||
// Arrange — negative: fee fiat rate unavailable, fiat fee text becomes the placeholder
|
||||
val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = null)
|
||||
|
||||
// Act
|
||||
val result = transformer.transform(prevState())
|
||||
|
||||
// Assert
|
||||
assertThat(result.isPrimaryButtonEnabled).isTrue()
|
||||
assertThat(result.yieldSupplyFeeUM).isEqualTo(
|
||||
YieldSupplyFeeUM.Content(
|
||||
transactionDataList = persistentListOf(),
|
||||
feeFiatValue = stringReference(fiatText(null)),
|
||||
estimatedFiatValue = TextReference.EMPTY,
|
||||
maxNetworkFeeFiatValue = TextReference.EMPTY,
|
||||
minTopUpFiatValue = TextReference.EMPTY,
|
||||
feeNoteValue = TextReference.EMPTY,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) }
|
||||
|
||||
private fun createTransformer(
|
||||
currencyStatus: CryptoCurrencyStatus,
|
||||
feeFiatRate: BigDecimal?,
|
||||
): YieldSupplyStopEarningFeeContentTransformer = YieldSupplyStopEarningFeeContentTransformer(
|
||||
cryptoCurrencyStatus = currencyStatus,
|
||||
feeCryptoCurrencyStatus = customStatus(feeFiatRate),
|
||||
appCurrency = appCurrency,
|
||||
transactions = emptyList(),
|
||||
feeValue = BigDecimal("0.5"),
|
||||
)
|
||||
|
||||
private fun customStatus(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus(
|
||||
currency = token,
|
||||
value = CryptoCurrencyStatus.Custom(
|
||||
amount = BigDecimal.ZERO,
|
||||
fiatAmount = BigDecimal.ZERO,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
stakingBalance = null,
|
||||
yieldSupplyStatus = null,
|
||||
hasCurrentNetworkTransactions = false,
|
||||
pendingTransactions = emptySet(),
|
||||
networkAddress = NetworkAddress.Single(
|
||||
defaultAddress = NetworkAddress.Address(
|
||||
value = "0x0000000000000000000000000000000000000000",
|
||||
type = NetworkAddress.Address.Type.Primary,
|
||||
),
|
||||
),
|
||||
sources = CryptoCurrencyStatus.Sources(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun loadingStatus(): CryptoCurrencyStatus =
|
||||
CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading)
|
||||
|
||||
private fun prevState(): YieldSupplyActionUM = YieldSupplyActionUM(
|
||||
title = stringReference(""),
|
||||
subtitle = stringReference(""),
|
||||
footer = stringReference(""),
|
||||
footerLink = stringReference(""),
|
||||
currencyIconState = mockk<CurrencyIconState>(relaxed = true),
|
||||
yieldSupplyFeeUM = YieldSupplyFeeUM.Error,
|
||||
isPrimaryButtonEnabled = false,
|
||||
isTransactionSending = false,
|
||||
isHoldToConfirmEnabled = false,
|
||||
)
|
||||
|
||||
private fun createToken(): CryptoCurrency.Token {
|
||||
val derivationPath = Network.DerivationPath.None
|
||||
val network = Network(
|
||||
id = Network.ID(value = "ethereum", derivationPath = derivationPath),
|
||||
name = "Ethereum",
|
||||
currencySymbol = "ETH",
|
||||
derivationPath = derivationPath,
|
||||
isTestnet = false,
|
||||
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
|
||||
hasFiatFeeRate = true,
|
||||
canHandleTokens = true,
|
||||
transactionExtrasType = Network.TransactionExtrasType.NONE,
|
||||
nameResolvingType = Network.NameResolvingType.NONE,
|
||||
)
|
||||
return CryptoCurrency.Token(
|
||||
id = CryptoCurrency.ID(
|
||||
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
|
||||
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
|
||||
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
|
||||
),
|
||||
network = network,
|
||||
name = "TEST_TOKEN",
|
||||
symbol = "TTK",
|
||||
decimals = 6,
|
||||
iconUrl = null,
|
||||
isCustom = false,
|
||||
contractAddress = "0xToken",
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue