Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-26 15:55:43 +03:00
parent a50cb15228
commit e2cd6813a0
19 changed files with 4599 additions and 0 deletions

View file

@ -0,0 +1,194 @@
package com.tangem.data.yield.supply
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.ethereum.EthereumUtils
import com.tangem.blockchain.common.WalletManager
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultYieldModuleAddressProviderTest {
private val walletManager: WalletManager = mockk()
private val walletManagersFacade: WalletManagersFacade = mockk()
private val provider = DefaultYieldModuleAddressProvider(
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("abcdef012345")
private val otherWalletId = UserWalletId("fedcba543210")
private val network = network()
@BeforeEach
fun setUp() {
clearMocks(walletManager, walletManagersFacade)
provider.invalidate(null)
coEvery {
walletManagersFacade.getOrCreateWalletManager(any(), any(), any())
} returns walletManager
}
@Test
fun `GIVEN non-zero address WHEN getOrFetch THEN returns and caches it`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
// Act
val first = provider.getOrFetch(userWalletId, network)
val second = provider.getOrFetch(userWalletId, network)
// Assert
assertThat(first).isEqualTo(ADDRESS)
assertThat(second).isEqualTo(ADDRESS)
coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN zero address WHEN getOrFetch THEN returns null and does not cache`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns EthereumUtils.ZERO_ADDRESS
// Act
val first = provider.getOrFetch(userWalletId, network)
val second = provider.getOrFetch(userWalletId, network)
// Assert — null result is never cached, so the manager is queried again
assertThat(first).isNull()
assertThat(second).isNull()
coVerify(exactly = 2) { walletManager.getYieldModuleAddress() }
}
@Test
fun `GIVEN missing wallet manager WHEN getOrFetch THEN throws`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null
// Act
val error = runCatching { provider.getOrFetch(userWalletId, network) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `GIVEN cached address WHEN invalidate for that wallet THEN it is refetched`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
provider.getOrFetch(userWalletId, network)
// Act
provider.invalidate(userWalletId)
provider.getOrFetch(userWalletId, network)
// Assert
coVerify(exactly = 2) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN two cached wallets WHEN invalidate one THEN only that one is refetched`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
provider.getOrFetch(userWalletId, network)
provider.getOrFetch(otherWalletId, network)
// Act
provider.invalidate(userWalletId)
provider.getOrFetch(userWalletId, network) // refetched
provider.getOrFetch(otherWalletId, network) // still cached
// Assert — 2 initial fetches + 1 refetch for the invalidated wallet only
coVerify(exactly = 3) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN cached addresses WHEN invalidate all THEN every wallet is refetched`() = runTest {
// Arrange
coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS
provider.getOrFetch(userWalletId, network)
provider.getOrFetch(otherWalletId, network)
// Act
provider.invalidate(null)
provider.getOrFetch(userWalletId, network)
provider.getOrFetch(otherWalletId, network)
// Assert — 2 initial + 2 after a full invalidation
coVerify(exactly = 4) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
}
@Test
fun `GIVEN two concurrent fetches for the same key WHEN one is in flight THEN manager is created once`() = runTest {
// Arrange — io dispatcher we control so both callers reach the mutex before the cache is populated
val testDispatcher = StandardTestDispatcher(testScheduler)
val concurrentProvider = DefaultYieldModuleAddressProvider(
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
val proceed = CompletableDeferred<Unit>()
coEvery { walletManager.getYieldModuleAddress() } coAnswers {
proceed.await()
ADDRESS
}
// Act — both pass the lock-free pre-check; one holds the lock and fetches, the other waits on it
val first = launch { concurrentProvider.getOrFetch(userWalletId, network) }
val second = launch { concurrentProvider.getOrFetch(userWalletId, network) }
runCurrent()
// At the barrier both callers have passed the lock-free pre-check (cache still empty): one holds the mutex and
// awaits the gate, the other is blocked on the lock. Asserting neither completed proves the second did NOT
// short-circuit on the outer pre-check, so it must hit the in-lock double-check once released.
assertThat(first.isCompleted).isFalse()
assertThat(second.isCompleted).isFalse()
proceed.complete(Unit)
advanceUntilIdle()
first.join()
second.join()
// Assert — the second caller is served from cache via the in-lock double-check
coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) }
coVerify(exactly = 1) { walletManager.getYieldModuleAddress() }
}
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 ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.yield.supply
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.yield.supply.YieldSupplyError
import org.junit.jupiter.api.Test
import java.io.IOException
internal class DefaultYieldSupplyErrorResolverTest {
@Test
fun `GIVEN a YieldSupplyError WHEN resolve THEN returns the same instance`() {
// Arrange
val error = YieldSupplyError.DataError(IOException("boom"))
// Act
val result = DefaultYieldSupplyErrorResolver.resolve(error)
// Assert
assertThat(result).isSameInstanceAs(error)
}
@Test
fun `GIVEN a generic throwable WHEN resolve THEN wraps it into DataError`() {
// Arrange
val throwable = IllegalStateException("unexpected")
// Act
val result = DefaultYieldSupplyErrorResolver.resolve(throwable)
// Assert
assertThat(result).isEqualTo(YieldSupplyError.DataError(throwable))
}
}

View file

@ -0,0 +1,470 @@
package com.tangem.data.yield.supply
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionStatus
import com.tangem.blockchain.common.WalletManager
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.YieldSupplyApi
import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse
import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.yieldsupply.YieldMarketsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.io.IOException
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultYieldSupplyRepositoryTest {
private val yieldSupplyApi: YieldSupplyApi = mockk()
private val store: YieldMarketsStore = mockk(relaxed = true)
private val walletManagersFacade: WalletManagersFacade = mockk()
private val analyticsExceptionHandler: AnalyticsExceptionHandler = mockk(relaxed = true)
private val appPreferencesStore: AppPreferencesStore = mockk()
private val repository = DefaultYieldSupplyRepository(
yieldSupplyApi = yieldSupplyApi,
store = store,
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(),
analyticsExceptionHandler = analyticsExceptionHandler,
appPreferencesStore = appPreferencesStore,
)
private val userWalletId = UserWalletId("abcdef012345")
private val token = token()
@BeforeEach
fun setUp() {
clearMocks(yieldSupplyApi, store, walletManagersFacade, analyticsExceptionHandler)
}
// region markets
@Test
fun `GIVEN cached dtos WHEN getCachedMarkets THEN returns enriched domain`() = runTest {
// Arrange
coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = 1))
// Act
val result = repository.getCachedMarkets()
// Assert — chainId 1 is enriched to its network id
assertThat(result).hasSize(1)
assertThat(result.first().backendId).isEqualTo("ethereum")
}
@Test
fun `GIVEN empty cache WHEN getCachedMarkets THEN returns empty list`() = runTest {
// Arrange
coEvery { store.getSyncOrNull() } returns null
// Act
val result = repository.getCachedMarkets()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN cached dto with unmapped chain id WHEN getCachedMarkets THEN backend id is null`() = runTest {
// Arrange — chainId -1 (the converter's default for a DTO without a chainId) maps to no network
coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = -1))
// Act
val result = repository.getCachedMarkets()
// Assert
assertThat(result).hasSize(1)
assertThat(result.first().backendId).isNull()
}
@Test
fun `GIVEN api returns markets WHEN updateMarkets THEN stores dtos and returns domain`() = runTest {
// Arrange
val dto = marketDto(chainId = 1)
coEvery { yieldSupplyApi.getYieldMarkets(any()) } returns ApiResponse.Success(
YieldMarketsResponse(marketDtos = listOf(dto), lastUpdated = "now"),
)
// Act
val result = repository.updateMarkets()
// Assert
assertThat(result).containsExactly(YieldMarketTokenConverter.convert(dto))
coVerify(exactly = 1) { store.store(listOf(dto)) }
}
@Test
fun `GIVEN store flow WHEN getMarketsFlow THEN emits enriched domain`() = runTest {
// Arrange
every { store.get() } returns flowOf(listOf(marketDto(chainId = 1)))
// Act
val result = repository.getMarketsFlow().first()
// Assert
assertThat(result.first().backendId).isEqualTo("ethereum")
}
// endregion
// region token status / chart
@Test
fun `GIVEN evm token WHEN getTokenStatus THEN returns converted market token`() = runTest {
// Arrange
val dto = marketDto(chainId = 1)
coEvery { yieldSupplyApi.getYieldTokenStatus(1, token.contractAddress) } returns ApiResponse.Success(dto)
// Act
val result = repository.getTokenStatus(token)
// Assert
assertThat(result).isEqualTo(YieldMarketTokenConverter.convert(dto))
}
@Test
fun `GIVEN non-evm token WHEN getTokenStatus THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.getTokenStatus(nonEvm) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `GIVEN evm token WHEN getTokenChart THEN returns converted chart`() = runTest {
// Arrange
coEvery { yieldSupplyApi.getYieldTokenChart(1, token.contractAddress) } returns ApiResponse.Success(
chartResponse(),
)
// Act
val result = repository.getTokenChart(token)
// Assert
assertThat(result.avr).isEqualTo(4.25)
assertThat(result.y).containsExactly(3.5).inOrder()
}
@Test
fun `GIVEN non-evm token WHEN getTokenChart THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.getTokenChart(nonEvm) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
// endregion
// region isYieldSupplySupported
@Test
fun `GIVEN supported yield provider WHEN isYieldSupplySupported THEN returns true`() = runTest {
// Arrange — WalletManager itself implements YieldSupplyProvider
val walletManager = mockk<WalletManager> { every { isSupported() } returns true }
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager
// Act
val result = repository.isYieldSupplySupported(userWalletId, token)
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN unsupported yield provider WHEN isYieldSupplySupported THEN returns false`() = runTest {
// Arrange
val walletManager = mockk<WalletManager> { every { isSupported() } returns false }
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager
// Act
val result = repository.isYieldSupplySupported(userWalletId, token)
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN no wallet manager WHEN isYieldSupplySupported THEN sends analytics and returns false`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null
// Act
val result = repository.isYieldSupplySupported(userWalletId, token)
// Assert
assertThat(result).isFalse()
verify { analyticsExceptionHandler.sendException(any()) }
}
// endregion
// region activate / deactivate
@Test
fun `GIVEN api returns active WHEN activateProtocol THEN returns true`() = runTest {
// Arrange
coEvery {
yieldSupplyApi.activateYieldModule(body = any(), userWalletId = any())
} returns ApiResponse.Success(statusResponse(isActive = true))
// Act
val result = repository.activateProtocol(userWalletId, token, ADDRESS)
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN non-evm token WHEN activateProtocol THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.activateProtocol(userWalletId, nonEvm, ADDRESS) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `GIVEN api returns inactive WHEN deactivateProtocol THEN returns false`() = runTest {
// Arrange
coEvery { yieldSupplyApi.deactivateYieldModule(any()) } returns ApiResponse.Success(
statusResponse(isActive = false),
)
// Act
val result = repository.deactivateProtocol(token, ADDRESS)
// Assert
assertThat(result).isFalse()
}
@Test
fun `GIVEN non-evm token WHEN deactivateProtocol THEN throws`() = runTest {
// Arrange
val nonEvm = token(rawId = "unknown-network-xyz")
// Act
val error = runCatching { repository.deactivateProtocol(nonEvm, ADDRESS) }.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IllegalStateException::class.java)
}
// endregion
// region pending status (in-memory)
@Test
fun `GIVEN saved pending status WHEN getTokenProtocolPendingStatus THEN returns it`() = runTest {
// Arrange
val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L)
repository.saveTokenProtocolPendingStatus(userWalletId, token, status)
// Act
val result = repository.getTokenProtocolPendingStatus(userWalletId, token)
// Assert
assertThat(result).isEqualTo(status)
}
@Test
fun `GIVEN saved then cleared WHEN getTokenProtocolPendingStatus THEN returns null`() = runTest {
// Arrange
repository.saveTokenProtocolPendingStatus(
userWalletId,
token,
YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L),
)
// Act
repository.saveTokenProtocolPendingStatus(userWalletId, token, null)
val result = repository.getTokenProtocolPendingStatus(userWalletId, token)
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN saved status WHEN flow collected THEN emits the status`() = runTest {
// Arrange
val status = YieldSupplyPendingStatus.Exit(txIds = listOf("0x9"), createdAt = 1L)
repository.saveTokenProtocolPendingStatus(userWalletId, token, status)
// Act
val emitted = repository.getTokenProtocolPendingStatusFlow(userWalletId, token).first()
// Assert
assertThat(emitted).isEqualTo(status)
}
// endregion
// region pending tx hashes
@Test
fun `GIVEN unconfirmed and confirmed txs WHEN getPendingTxHashes THEN returns only unconfirmed hashes`() = runTest {
// Arrange
val walletManager = mockk<WalletManager> {
every { wallet.recentTransactions } returns mutableListOf(
tx(TransactionStatus.Unconfirmed, "0xUnconfirmed"),
tx(TransactionStatus.Confirmed, "0xConfirmed"),
)
}
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any<Network>()) } returns walletManager
// Act
val result = repository.getPendingTxHashes(userWalletId, token)
// Assert
assertThat(result).containsExactly("0xUnconfirmed")
}
@Test
fun `GIVEN no wallet manager WHEN getPendingTxHashes THEN returns empty`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any<Network>()) } returns null
// Act
val result = repository.getPendingTxHashes(userWalletId, token)
// Assert
assertThat(result).isEmpty()
}
// endregion
// region promo banner preference
@Test
fun `GIVEN stored flag WHEN getShouldShowYieldPromoBanner THEN emits it`() = runTest {
// Arrange
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
try {
every {
appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true)
} returns flowOf(false)
// Act
val result = repository.getShouldShowYieldPromoBanner().first()
// Assert
assertThat(result).isFalse()
} finally {
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
}
@Test
fun `WHEN setShouldShowYieldPromoBanner THEN stores the value`() = runTest {
// Arrange
mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
try {
coEvery {
appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false)
} returns Unit
// Act
repository.setShouldShowYieldPromoBanner(false)
// Assert
coVerify { appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false) }
} finally {
unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt")
}
}
// endregion
private fun tx(status: TransactionStatus, hash: String): TransactionData.Uncompiled = mockk {
every { this@mockk.status } returns status
every { this@mockk.hash } returns hash
}
private fun marketDto(chainId: Int) = YieldSupplyMarketTokenDto(
tokenAddress = "0xToken",
tokenSymbol = "USDT",
tokenName = "Tether",
apy = BigDecimal("5.5"),
decimals = 6,
isActive = true,
chainId = chainId,
maxFeeNative = BigDecimal("0.005"),
maxFeeUSD = BigDecimal("12.34"),
)
private fun chartResponse() = YieldTokenChartResponse(
underlying = "USDT",
market = "aave",
bucketSizeDays = 1,
period = "30d",
data = listOf(YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5"))),
averageApy = BigDecimal("4.25"),
)
private fun statusResponse(isActive: Boolean) = YieldModuleStatusResponse(
tokenAddress = "0xToken",
chainId = 1,
isActive = isActive,
activatedAt = null,
deactivatedAt = null,
)
private fun token(rawId: String = "ethereum", contractAddress: String = "0xToken"): CryptoCurrency.Token {
val derivationPath = Network.DerivationPath.None
val network = Network(
id = Network.ID(value = rawId, derivationPath = derivationPath),
name = "Net",
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(rawId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawId),
),
network = network,
name = "TEST_TOKEN",
symbol = "TTK",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = contractAddress,
)
}
private companion object {
const val ADDRESS = "0x1111111111111111111111111111111111111111"
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.data.yield.supply.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto
import com.tangem.domain.yield.supply.models.YieldMarketToken
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class YieldMarketTokenConverterTest {
@Test
fun `GIVEN fully populated dto WHEN convert THEN maps every field`() {
// Arrange
val dto = YieldSupplyMarketTokenDto(
tokenAddress = "0xToken",
tokenSymbol = "USDT",
tokenName = "Tether",
apy = BigDecimal("5.5"),
decimals = 6,
isActive = true,
chainId = 1,
maxFeeNative = BigDecimal("0.005"),
maxFeeUSD = BigDecimal("12.34"),
)
// Act
val result = YieldMarketTokenConverter.convert(dto)
// Assert
assertThat(result).isEqualTo(
YieldMarketToken(
tokenAddress = "0xToken",
chainId = 1,
apy = BigDecimal("5.5"),
isActive = true,
maxFeeNative = BigDecimal("0.005"),
maxFeeUSD = BigDecimal("12.34"),
backendId = null,
),
)
}
@Test
fun `GIVEN dto with null fields WHEN convert THEN applies defaults`() {
// Arrange
val dto = YieldSupplyMarketTokenDto()
// Act
val result = YieldMarketTokenConverter.convert(dto)
// Assert
assertThat(result).isEqualTo(
YieldMarketToken(
tokenAddress = "",
chainId = -1,
apy = BigDecimal.ZERO,
isActive = false,
maxFeeNative = BigDecimal.ZERO,
maxFeeUSD = BigDecimal.ZERO,
backendId = null,
),
)
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.data.yield.supply.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse
import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class YieldTokenChartConverterTest {
@Test
fun `GIVEN response with data points WHEN convert THEN splits avgApy into y and bucketIndex into x preserving order`() {
// Arrange
val response = response(
averageApy = BigDecimal("4.25"),
points = listOf(
YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5")),
YieldTokenChartResponse.DataPoint(bucketIndex = 1, avgApy = BigDecimal("4.0")),
YieldTokenChartResponse.DataPoint(bucketIndex = 2, avgApy = BigDecimal("5.0")),
),
)
// Act
val result = YieldTokenChartConverter.convert(response)
// Assert
assertThat(result).isEqualTo(
YieldSupplyMarketChartData(
y = listOf(3.5, 4.0, 5.0),
x = listOf(0.0, 1.0, 2.0),
avr = 4.25,
),
)
}
@Test
fun `GIVEN response with empty data WHEN convert THEN returns empty y and x with average`() {
// Arrange
val response = response(averageApy = BigDecimal("1.0"), points = emptyList())
// Act
val result = YieldTokenChartConverter.convert(response)
// Assert
assertThat(result).isEqualTo(
YieldSupplyMarketChartData(y = emptyList(), x = emptyList(), avr = 1.0),
)
}
private fun response(averageApy: BigDecimal, points: List<YieldTokenChartResponse.DataPoint>) =
YieldTokenChartResponse(
underlying = "USDT",
market = "aave",
bucketSizeDays = 1,
period = "30d",
data = points,
averageApy = averageApy,
)
}

View file

@ -0,0 +1,245 @@
package com.tangem.data.yield.supply.promo
import com.google.common.truth.Truth.assertThat
import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter
import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.promotion.models.PromotionsResponse
import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore
import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.io.IOException
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultYieldPromoRepositoryTest {
private val tangemApi: TangemTechApi = mockk()
private val promoStore: YieldBoostPromoStore = mockk(relaxed = true)
private val statusStore: YieldBoostStatusStore = mockk(relaxed = true)
private val repository = DefaultYieldPromoRepository(
tangemApi = tangemApi,
promoStore = promoStore,
statusStore = statusStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("abcdef012345")
@BeforeEach
fun setUp() {
clearMocks(tangemApi, promoStore, statusStore)
}
// region getYieldBoostPromo
@Test
fun `GIVEN cached promo and no refresh WHEN getYieldBoostPromo THEN returns cache without api`() = runTest {
// Arrange
val cached = YieldBoostPromo.None
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN no cache WHEN getYieldBoostPromo THEN fetches stores and returns converted`() = runTest {
// Arrange
val dto = matchingPromoDto()
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(dto)),
)
val expected = YieldBoostPromoConverter.convert(dto)
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(expected)
coVerify(exactly = 1) { promoStore.store(userWalletId, expected) }
}
@Test
fun `GIVEN cached promo and force refresh WHEN getYieldBoostPromo THEN fetches anyway`() = runTest {
// Arrange
coEvery { promoStore.getSyncOrNull(userWalletId) } returns YieldBoostPromo.None
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(matchingPromoDto())),
)
// Act
repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
// Assert
coVerify(exactly = 1) { tangemApi.getPromotions(any(), any()) }
}
@Test
fun `GIVEN no matching promo name WHEN getYieldBoostPromo THEN returns None`() = runTest {
// Arrange
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success(
PromotionsResponse(promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null))),
)
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(YieldBoostPromo.None)
coVerify(exactly = 1) { promoStore.store(userWalletId, YieldBoostPromo.None) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getYieldBoostPromo THEN falls back to cache`() = runTest {
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
val cached = YieldBoostPromo.None
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { promoStore.store(any(), any()) }
}
@Test
fun `GIVEN fetch fails and no cache WHEN getYieldBoostPromo THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network")
coEvery { promoStore.getSyncOrNull(userWalletId) } returns null
// Act
val error = runCatching { repository.getYieldBoostPromo(userWalletId, forceRefresh = true) }
.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
// endregion
// region getYieldBoostStatus
@Test
fun `GIVEN cached status and no refresh WHEN getYieldBoostStatus THEN returns cache without api`() = runTest {
// Arrange
val cached = YieldBoostStatus.NotStarted
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { tangemApi.getYieldBoostStatus(any()) }
}
@Test
fun `GIVEN no cache WHEN getYieldBoostStatus THEN fetches stores and returns converted`() = runTest {
// Arrange
val response = statusResponse()
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(response)
val expected = YieldBoostStatusConverter.convert(response)
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false)
// Assert
assertThat(result).isEqualTo(expected)
coVerify(exactly = 1) { statusStore.store(userWalletId, expected) }
}
@Test
fun `GIVEN cached status and force refresh WHEN getYieldBoostStatus THEN fetches anyway`() = runTest {
// Arrange
coEvery { statusStore.getSyncOrNull(userWalletId) } returns YieldBoostStatus.NotStarted
coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(statusResponse())
// Act
repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
// Assert
coVerify(exactly = 1) { tangemApi.getYieldBoostStatus(any()) }
}
@Test
fun `GIVEN fetch fails and cache present WHEN getYieldBoostStatus THEN falls back to cache`() = runTest {
// Arrange — force refresh so the initial cache check is skipped and the fetch is attempted
val cached = YieldBoostStatus.NotStarted
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached
// Act
val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = true)
// Assert
assertThat(result).isEqualTo(cached)
coVerify(exactly = 0) { statusStore.store(any(), any()) }
}
@Test
fun `GIVEN fetch fails and no cache WHEN getYieldBoostStatus THEN rethrows`() = runTest {
// Arrange
coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network")
coEvery { statusStore.getSyncOrNull(userWalletId) } returns null
// Act
val error = runCatching { repository.getYieldBoostStatus(userWalletId, forceRefresh = true) }
.exceptionOrNull()
// Assert
assertThat(error).isInstanceOf(IOException::class.java)
}
// endregion
private fun matchingPromoDto() = PromotionsResponse.PromotionDto(
name = "yield-apr-boost",
all = PromotionsResponse.PromotionDto.All(
timeline = PromotionsResponse.PromotionDto.Timeline(
start = "2026-06-15T00:00:00.000Z",
end = "2027-06-15T22:00:00.000Z",
),
tokens = listOf(
PromotionsResponse.PromotionDto.PromoToken(
tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
tokenSymbol = "USDC",
tokenName = "USD Coin",
networkId = "ethereum",
),
),
status = "active",
link = "https://example.com/terms",
),
)
private fun statusResponse() = YieldBoostStatusResponse(
tokenName = "USD Coin",
networkId = "ethereum",
moduleAddress = "0xModule",
userAddress = "0xUser",
contractAddress = "0xContract",
promoEnrollmentStatus = "NOT_STARTED",
qualificationEndDate = null,
disqualificationReason = null,
)
}

View file

@ -0,0 +1,406 @@
package com.tangem.domain.yield.supply.usecase
import arrow.core.Either
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.SingleAccountListSupplier
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.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.yield.supply.YieldSupplyRepository
import com.tangem.domain.yield.supply.models.YieldMarketToken
import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.math.RoundingMode
@OptIn(ExperimentalCoroutinesApi::class)
internal class YieldSupplyGetMaxFeeUseCaseTest {
private val yieldSupplyRepository: YieldSupplyRepository = mockk()
private val quotesRepository: QuotesRepository = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val useCase = YieldSupplyGetMaxFeeUseCase(
yieldSupplyRepository = yieldSupplyRepository,
quotesRepository = quotesRepository,
singleAccountListSupplier = singleAccountListSupplier,
)
private val userWalletId = UserWalletId("abcdef012345")
@BeforeEach
fun setUp() {
clearMocks(yieldSupplyRepository, quotesRepository, singleAccountListSupplier)
}
@Test
fun `GIVEN cached market token WHEN invoke THEN converts and HALF_UP-rounds the fee to token and fiat`() =
runTest {
// Arrange — values chosen to pin the formula AND the rounding mode with literal expectations:
// fiatMaxFee = maxFeeNative(0.0002) * nativeFiatRate(1000) = 0.2
// tokenMaxFee = 0.2 / tokenFiatRate(3) = 0.066666… → 0.066667 at 6 decimals (HALF_UP; HALF_DOWN = 0.066666)
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("3"))
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, fiatRate = BigDecimal("1000"))
coEvery { yieldSupplyRepository.getCachedMarkets() } returns listOf(
createMarketToken(token = token, maxFeeNative = BigDecimal("0.0002")),
)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert — literal expectations, not a mirror of the production expression
assertThat(result).isEqualTo(
Either.Right(
YieldSupplyMaxFee(
nativeMaxFee = BigDecimal("0.0002"),
tokenMaxFee = BigDecimal("0.066667"),
fiatMaxFee = BigDecimal("0.2"),
),
),
)
coVerify(exactly = 0) { yieldSupplyRepository.getTokenStatus(any()) }
}
@Test
fun `GIVEN no matching cached token WHEN invoke THEN falls back to fetching token status`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
val nativeFiatRate = BigDecimal("2000.00")
val maxFeeNative = BigDecimal("0.005")
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, nativeFiatRate)
coEvery { yieldSupplyRepository.getCachedMarkets() } returns emptyList()
coEvery { yieldSupplyRepository.getTokenStatus(token) } returns createMarketToken(
token = token,
maxFeeNative = maxFeeNative,
)
val fiatMaxFee = maxFeeNative.multiply(nativeFiatRate)
val expected = YieldSupplyMaxFee(
nativeMaxFee = maxFeeNative,
tokenMaxFee = fiatMaxFee.divide(cryptoStatus.value.fiatRate, token.decimals, RoundingMode.HALF_UP),
fiatMaxFee = fiatMaxFee.stripTrailingZeros(),
)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertThat(result).isEqualTo(Either.Right(expected))
coVerify(exactly = 1) { yieldSupplyRepository.getTokenStatus(token) }
}
@Test
fun `GIVEN null cached markets WHEN invoke THEN falls back to fetching token status`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
val nativeFiatRate = BigDecimal("2000.00")
val maxFeeNative = BigDecimal("0.005")
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, nativeFiatRate)
coEvery { yieldSupplyRepository.getCachedMarkets() } returns null
coEvery { yieldSupplyRepository.getTokenStatus(token) } returns createMarketToken(
token = token,
maxFeeNative = maxFeeNative,
)
val fiatMaxFee = maxFeeNative.multiply(nativeFiatRate)
val expected = YieldSupplyMaxFee(
nativeMaxFee = maxFeeNative,
tokenMaxFee = fiatMaxFee.divide(cryptoStatus.value.fiatRate, token.decimals, RoundingMode.HALF_UP),
fiatMaxFee = fiatMaxFee.stripTrailingZeros(),
)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertThat(result).isEqualTo(Either.Right(expected))
coVerify(exactly = 1) { yieldSupplyRepository.getTokenStatus(token) }
}
@Test
fun `GIVEN currency is not a token WHEN invoke THEN returns error`() = runTest {
// Arrange
val coinStatus = createCoinStatus(createCoin(rawNetworkId = NETWORK_ID, decimals = 18))
// Act
val result = useCase(userWalletId, coinStatus)
// Assert
assertLeftWithMessage(result, "CryptoCurrency must be token for max fee calculation")
}
@Test
fun `GIVEN token fiat rate missing WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = null)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Fiat rate is missing")
}
@Test
fun `GIVEN token fiat rate non-positive WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal.ZERO)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Fiat rate for token must be > 0")
}
@Test
fun `GIVEN account status list missing WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) } returns null
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftStartingWith(result, "Account status list is missing")
}
@Test
fun `GIVEN native coin not found in account list WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
coEvery {
singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId)
} returns AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(token))
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftStartingWith(result, "Unable to find coin for network ID")
}
@Test
fun `GIVEN native quotes unavailable WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns null
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Quotes for native coin are unavailable")
}
@Test
fun `GIVEN empty native quotes list WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns emptySet()
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Empty quotes list for native coin")
}
@Test
fun `GIVEN native quote has no fiat rate WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns setOf(QuoteStatus(rawCurrencyId = nativeCoin.id.rawCurrencyId!!))
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Native fiat rate is missing")
}
@Test
fun `GIVEN native fiat rate non-positive WHEN invoke THEN returns error`() = runTest {
// Arrange
val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6)
val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18)
val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00"))
stubAccountList(token, nativeCoin)
stubNativeQuote(nativeCoin, fiatRate = BigDecimal.ZERO)
// Act
val result = useCase(userWalletId, cryptoStatus)
// Assert
assertLeftWithMessage(result, "Native fiat rate must be > 0")
}
// region Helpers
private fun stubAccountList(token: CryptoCurrency.Token, nativeCoin: CryptoCurrency.Coin) {
coEvery {
singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId)
} returns AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(nativeCoin, token))
}
private fun stubNativeQuote(nativeCoin: CryptoCurrency.Coin, fiatRate: BigDecimal) {
coEvery {
quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!))
} returns setOf(
QuoteStatus(
rawCurrencyId = nativeCoin.id.rawCurrencyId!!,
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatRate = fiatRate,
fiatRateUSD = fiatRate,
priceChange = BigDecimal.ZERO,
),
),
)
}
private fun assertLeftWithMessage(result: Either<Throwable, YieldSupplyMaxFee>, message: String) {
assertThat(result.isLeft()).isTrue()
assertThat((result as Either.Left).value.message).isEqualTo(message)
}
private fun assertLeftStartingWith(result: Either<Throwable, YieldSupplyMaxFee>, prefix: String) {
assertThat(result.isLeft()).isTrue()
assertThat((result as Either.Left).value.message).startsWith(prefix)
}
private fun createMarketToken(token: CryptoCurrency.Token, maxFeeNative: BigDecimal): YieldMarketToken =
YieldMarketToken(
tokenAddress = token.contractAddress,
chainId = 1,
apy = BigDecimal.ZERO,
isActive = true,
maxFeeNative = maxFeeNative,
maxFeeUSD = BigDecimal.ZERO,
backendId = token.network.rawId,
)
private fun createToken(rawNetworkId: String, decimals: Int): CryptoCurrency.Token {
return CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = createNetwork(rawNetworkId),
name = "TEST_TOKEN",
symbol = "TTK",
decimals = decimals,
iconUrl = null,
isCustom = false,
contractAddress = "0xToken",
)
}
private fun createCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = createNetwork(rawNetworkId),
name = "TEST_COIN",
symbol = "TCN",
decimals = decimals,
iconUrl = null,
isCustom = false,
)
}
private fun createNetwork(rawNetworkId: String): Network {
val derivationPath = Network.DerivationPath.None
return Network(
id = Network.ID(value = rawNetworkId, derivationPath = derivationPath),
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = derivationPath,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
private fun createTokenStatus(token: CryptoCurrency.Token, fiatRate: BigDecimal?): CryptoCurrencyStatus =
CryptoCurrencyStatus(currency = token, value = customValue(fiatRate))
private fun createCoinStatus(coin: CryptoCurrency.Coin): CryptoCurrencyStatus =
CryptoCurrencyStatus(currency = coin, value = customValue(BigDecimal.ONE))
private fun customValue(fiatRate: BigDecimal?): CryptoCurrencyStatus.Custom = 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(),
)
// endregion
private companion object {
const val NETWORK_ID = "ethereum"
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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