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