Updated on 2026-08-14

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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