From e2cd6813a0a1595cc150c882901036bee82bb828 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 15:55:43 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultYieldModuleAddressProviderTest.kt | 194 +++++ .../DefaultYieldSupplyErrorResolverTest.kt | 33 + .../DefaultYieldSupplyRepositoryTest.kt | 470 ++++++++++++ .../YieldMarketTokenConverterTest.kt | 64 ++ .../YieldTokenChartConverterTest.kt | 59 ++ .../promo/DefaultYieldPromoRepositoryTest.kt | 245 +++++++ .../YieldSupplyGetMaxFeeUseCaseTest.kt | 406 ++++++++++ ...ldSupplyActiveFeeContentTransformerTest.kt | 198 +++++ ...eldSupplyActiveMinAmountTransformerTest.kt | 325 ++++++++ .../chart/model/YieldSupplyChartModelTest.kt | 172 +++++ .../entry/model/YieldSupplyEntryModelTest.kt | 310 ++++++++ .../impl/main/model/YieldSupplyModelTest.kt | 691 ++++++++++++++++++ ...SupplyTokenStatusSuccessTransformerTest.kt | 122 ++++ .../YieldSupplyActionModelTestBase.kt | 188 +++++ .../model/YieldSupplyApproveModelTest.kt | 244 +++++++ .../model/YieldSupplyStartEarningModelTest.kt | 278 +++++++ ...lyStartEarningFeeContentTransformerTest.kt | 192 +++++ .../model/YieldSupplyStopEarningModelTest.kt | 247 +++++++ ...plyStopEarningFeeContentTransformerTest.kt | 161 ++++ 19 files changed, 4599 insertions(+) create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt new file mode 100644 index 0000000000..0196128ec2 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt @@ -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() + 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" + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt new file mode 100644 index 0000000000..32c6b3be6e --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt @@ -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)) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt new file mode 100644 index 0000000000..07a94446cc --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt @@ -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 { 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 { 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 { + every { wallet.recentTransactions } returns mutableListOf( + tx(TransactionStatus.Unconfirmed, "0xUnconfirmed"), + tx(TransactionStatus.Confirmed, "0xConfirmed"), + ) + } + coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any()) } 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()) } 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" + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt new file mode 100644 index 0000000000..8e68d3e36f --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt @@ -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, + ), + ) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt new file mode 100644 index 0000000000..7025885c1f --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt @@ -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( + underlying = "USDT", + market = "aave", + bucketSizeDays = 1, + period = "30d", + data = points, + averageApy = averageApy, + ) +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt new file mode 100644 index 0000000000..c8d8cbf95d --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt new file mode 100644 index 0000000000..229eb20309 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt @@ -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, message: String) { + assertThat(result.isLeft()).isTrue() + assertThat((result as Either.Left).value.message).isEqualTo(message) + } + + private fun assertLeftStartingWith(result: Either, 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" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt new file mode 100644 index 0000000000..8139340549 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt @@ -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() + + // 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", + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt new file mode 100644 index 0000000000..16fd1476b0 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt @@ -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() + + // 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") + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt new file mode 100644 index 0000000000..65447d0488 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt @@ -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): 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 + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt new file mode 100644 index 0000000000..560732b75f --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt @@ -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() + verify { router.replaceCurrent(capture(slot), any()) } + return slot.captured + } + + private fun stubStatusLookup(result: arrow.core.Option) { + every { + with(CryptoCurrencyStatusOperations) { + accountStatusList.getCryptoCurrencyStatus(any()) + } + } 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") + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt new file mode 100644 index 0000000000..359d3fd1aa --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt @@ -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(relaxed = true).left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial) + verify(exactly = 0) { accountStatusListSupplier(any()) } + 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()) } + } + + @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() + verify { analytics.send(capture(events)) } + val approveEvent = events.filterIsInstance().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() + + // 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() + + // 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() + + // 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, list: AccountStatusList = accountStatusList) { + every { + with(CryptoCurrencyStatusOperations) { list.getCryptoCurrencyStatus(any()) } + } 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" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt new file mode 100644 index 0000000000..ef0905610f --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt @@ -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, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt new file mode 100644 index 0000000000..2f73937b15 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt @@ -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" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt new file mode 100644 index 0000000000..8988b4b388 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt @@ -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() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().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() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().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 = 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" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt new file mode 100644 index 0000000000..7418b9df04 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt @@ -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(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) { + every { + with(CryptoCurrencyStatusOperations) { + accountStatusList.getCryptoCurrencyStatus(any()) + } + } 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(relaxed = true), + yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, + isPrimaryButtonEnabled = false, + isTransactionSending = false, + isHoldToConfirmEnabled = false, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt new file mode 100644 index 0000000000..92246b3d35 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt @@ -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(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") + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt new file mode 100644 index 0000000000..7fcf23b5a0 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt @@ -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() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().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() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().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 = 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" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt new file mode 100644 index 0000000000..a0b390a229 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt @@ -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(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", + ) + } +} \ No newline at end of file