Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-17 13:36:49 +02:00
commit 63832bf7e7
56 changed files with 1540 additions and 270 deletions

View file

@ -119,7 +119,9 @@ internal class DefaultNFTRepository @Inject constructor(
userWalletId: UserWalletId,
networks: List<Network>,
): Flow<List<NFTCollections>> = combine(
networks.map { observeCollectionsInternal(userWalletId, it) },
networks
.sortedBy { it.name }
.map { observeCollectionsInternal(userWalletId, it) },
) { it.asList() }
private suspend fun observeCollectionsInternal(

View file

@ -13,6 +13,10 @@ android {
namespace = "com.tangem.data.staking"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core modules */
implementation(projects.core.datasource)
@ -64,7 +68,8 @@ dependencies {
// endregion
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(tangemDeps.card.core)

View file

@ -246,6 +246,47 @@ internal class DefaultStakingRepository(
}
}
override suspend fun getStakingAvailabilitySync(
userWalletId: UserWalletId,
cryptoCurrency: CryptoCurrency,
): StakingAvailability {
if (!checkFeatureToggleEnabled(cryptoCurrency.network.id)) {
return StakingAvailability.Unavailable
}
if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) {
return StakingAvailability.Unavailable
}
val rawCurrencyId = cryptoCurrency.id.rawCurrencyId
if (rawCurrencyId == null) {
return StakingAvailability.Unavailable
}
val isSupportedInMobileApp = getSupportedIntegrationId(cryptoCurrency.id).isNullOrEmpty().not()
val yields = getEnabledYieldsSync()
if (yields.isEmpty()) {
return StakingAvailability.TemporaryUnavailable
}
val prefetchedYield = findPrefetchedYield(
yields = yields,
currencyId = rawCurrencyId,
symbol = cryptoCurrency.symbol,
)
return when {
prefetchedYield != null && isSupportedInMobileApp -> {
StakingAvailability.Available(prefetchedYield.id)
}
prefetchedYield == null && isSupportedInMobileApp -> {
StakingAvailability.TemporaryUnavailable
}
else -> StakingAvailability.Unavailable
}
}
private fun checkFeatureToggleEnabled(networkId: Network.ID): Boolean {
return when (networkId.toBlockchain()) {
Blockchain.TON -> stakingFeatureToggles.isTonStakingEnabled

View file

@ -0,0 +1,210 @@
package com.tangem.data.staking.multi
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.left
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.toOption
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
import javax.inject.Inject
/**
* Default implementation of [MultiYieldBalanceFetcher]
*
* @property userWalletsStore user wallets store
* @property stakingYieldsStore staking yields store
* @property yieldsBalancesStore yields balances store
* @property stakingIdFactory factory for creating StakingID
* @property stakeKitApi stake kit API
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiYieldBalanceFetcherV2 @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val stakingYieldsStore: StakingYieldsStore,
private val yieldsBalancesStore: YieldsBalancesStore,
private val stakingIdFactory: StakingIdFactory,
private val stakeKitApi: StakeKitApi,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun invoke(params: MultiYieldBalanceFetcher.Params): Either<Throwable, Unit> {
checkIsSupportedByWalletOrElse(userWalletId = params.userWalletId) {
return it.left()
}
val stakingIds = getStakingIds(params).getOrElse {
return it.left()
}
return Either.catchOn(dispatchers.default) {
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = stakingIds)
val availableStakingIds = getAvailableStakingIds(
userWalletId = params.userWalletId,
stakingIds = stakingIds,
)
fetch(params = params, stakingIds = availableStakingIds)
}
.onLeft {
Timber.e(it, "Unable to fetch yield balances $params")
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds)
}
}
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {
val maybeUserWallet = userWalletsStore.getSyncOrNull(key = userWalletId).toOption()
val isSupportedByWallet = maybeUserWallet.isSome(UserWallet::isMultiCurrency)
if (!isSupportedByWallet) {
val exception = IllegalStateException("Wallet $userWalletId is not supported: $maybeUserWallet")
Timber.e(exception)
ifNotSupported(exception)
}
}
private suspend fun getStakingIds(params: MultiYieldBalanceFetcher.Params) = either {
val stakingIds = catch(
block = {
params.currencyIdWithNetworkMap.flatMapTo(hashSetOf()) { (currencyId, network) ->
stakingIdFactory.create(
userWalletId = params.userWalletId,
currencyId = currencyId,
network = network,
)
}
},
catch = ::raise,
)
ensure(stakingIds.isNotEmpty()) {
val exception = IllegalStateException("Unable to create staking ids for $params: list is empty")
Timber.e(exception)
raise(exception)
}
stakingIds
}
private suspend fun getAvailableStakingIds(userWalletId: UserWalletId, stakingIds: Set<StakingID>): Set<StakingID> {
val yieldIds = getYieldsIds(userWalletId = userWalletId)
// [true] -> available
// [false] -> unavailable
val groupedStakingIds = stakingIds.groupBy { stakingId ->
yieldIds.any { it == stakingId.integrationId }
}
val availableStakingIds = groupedStakingIds[true].orEmpty()
val unavailableStakingIds = groupedStakingIds[false].orEmpty()
if (unavailableStakingIds.isNotEmpty()) {
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = unavailableStakingIds.toSet())
}
return availableStakingIds.toSet().ifEmpty {
val exception = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${stakingIds.joinToString()}
""".trimIndent(),
)
Timber.d(exception)
throw exception
}
}
private suspend fun getYieldsIds(userWalletId: UserWalletId): Set<String> {
val yieldsIds = stakingYieldsStore.getSyncWithTimeout().orEmpty()
.mapNotNullTo(destination = hashSetOf(), transform = YieldDTO::id)
if (yieldsIds.isEmpty()) {
val exception = IllegalStateException("No enabled yields for $userWalletId")
Timber.e(exception)
throw exception
}
return yieldsIds
}
suspend fun fetch(params: MultiYieldBalanceFetcher.Params, stakingIds: Set<StakingID>) {
safeApiCall(
call = {
val requests = stakingIds.map(YieldBalanceRequestBodyFactory::create)
val yieldBalances = coroutineScope {
requests
.chunked(size = 16)
.map {
async(dispatchers.io) { stakeKitApi.getMultipleYieldBalances(it).bind() }
}
.awaitAll()
.flatten()
.toSet()
}
yieldsBalancesStore.storeActual(userWalletId = params.userWalletId, values = yieldBalances)
if (!allResponsesReceived(requests, yieldBalances)) {
val values = stakingIds.filter { stakingId ->
yieldBalances.none {
stakingId.integrationId == it.integrationId &&
stakingId.address == it.addresses.address
}
}
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = values.toSet())
}
},
onError = {
Timber.e(it, "Unable to fetch yield balances $params")
yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds)
throw it
},
)
}
private fun allResponsesReceived(
requests: List<YieldBalanceRequestBody>,
yieldBalances: Set<YieldBalanceWrapperDTO>,
): Boolean {
return requests.all { request ->
yieldBalances.any {
request.integrationId == it.integrationId &&
request.addresses.address == it.addresses.address
}
}
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.staking.single
import arrow.core.Either
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
import javax.inject.Inject
/**
* Default implementation of [MultiYieldBalanceFetcher]
*
* @property multiYieldBalanceFetcher multi yield balance fetcher
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleYieldBalanceFetcherV2 @Inject constructor(
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher,
) {
suspend fun invoke(params: SingleYieldBalanceFetcher.Params): Either<Throwable, Unit> {
return multiYieldBalanceFetcher(
params = YieldBalanceFetcherParams.Multi(
userWalletId = params.userWalletId,
currencyIdWithNetworkMap = mapOf(
params.currencyId to params.network,
),
),
)
}
}

View file

@ -0,0 +1,470 @@
package com.tangem.data.staking.multi
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.data.staking.MockYieldDTOFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.utils.StakingIdFactory
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.staking.model.StakingID
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiYieldBalanceFetcherV2Test {
private val userWalletsStore: UserWalletsStore = mockk()
private val stakingYieldsStore: StakingYieldsStore = mockk()
private val yieldsBalancesStore: YieldsBalancesStore = mockk()
private val stakingIdFactory: StakingIdFactory = mockk()
private val stakeKitApi: StakeKitApi = mockk()
private val fetcher = DefaultMultiYieldBalanceFetcherV2(
userWalletsStore = userWalletsStore,
stakingYieldsStore = stakingYieldsStore,
yieldsBalancesStore = yieldsBalancesStore,
stakingIdFactory = stakingIdFactory,
stakeKitApi = stakeKitApi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsStore, stakingYieldsStore, yieldsBalancesStore, stakingIdFactory, stakeKitApi)
}
@Test
fun `fetch yields balances successfully`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val requests = tonAndSolanaIds.map(YieldBalanceRequestBodyFactory::create).sortedBy { it.integrationId }
val result = setOf(
MockYieldBalanceWrapperDTOFactory.createWithBalance(solanaId),
MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId),
)
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns ApiResponse.Success(result)
coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result)
}
coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) }
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch yields balances successfully if one of stakingIds is unavailable`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId)) } just Runs
val requests = listOf(YieldBalanceRequestBodyFactory.create(tonId))
val result = setOf(MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId))
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns ApiResponse.Success(result)
coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(solanaId))
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = result)
}
Truth.assertThat(actual.isRight()).isTrue()
}
@Test
fun `fetch yields balances failure if user wallet is not supported`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${userWallet.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if userWalletsStore returns null`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns null
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
coVerify(inverse = true) {
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
yieldsBalancesStore.storeError(userWalletId = any(), stakingIds = any())
}
val expected = IllegalStateException("Wallet ${params.userWalletId} is not supported: ${null.toOption()}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingIdFactory returns empty list`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns emptySet()
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns emptySet()
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
}
coVerify(inverse = true) {
yieldsBalancesStore.refresh(any(), any<Set<StakingID>>())
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(any(), any())
yieldsBalancesStore.storeError(any(), any())
}
val expected = IllegalStateException("Unable to create staking ids for $params: list is empty")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns null`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakingYieldsStore getSyncWithTimeout returns empty list`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList()
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if yields converting is failed`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(
MockYieldDTOFactory.create(tonId).copy(id = null),
MockYieldDTOFactory.create(solanaId).copy(id = null),
)
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException("No enabled yields for ${params.userWalletId}")
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if available yields does not contain ids from params`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1")))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds)
}
coVerify(inverse = true) {
stakeKitApi.getMultipleYieldBalances(any())
yieldsBalancesStore.storeActual(userWalletId = any(), values = any())
}
val expected = IllegalStateException(
"""
No available yields to fetch yield balances:
userWalletId: $userWalletId
stakingIds: ${setOf(solanaId, tonId).joinToString()}
""".trimIndent(),
)
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
@Test
fun `fetch yields balances failure if stakeKitApi getMultipleYieldBalances is failed`() = runTest {
// Arrange
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields
val requests = setOf(solanaId, tonId).map(YieldBalanceRequestBodyFactory::create)
@Suppress("UNCHECKED_CAST")
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException)
as ApiResponse<Set<YieldBalanceWrapperDTO>>
coEvery { stakeKitApi.getMultipleYieldBalances(requests) } returns errorResponse
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify {
userWalletsStore.getSyncOrNull(params.userWalletId)
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
stakingIdFactory.create(params.userWalletId, solana.id, solana.network)
yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = tonAndSolanaIds)
stakingYieldsStore.getSyncWithTimeout()
stakeKitApi.getMultipleYieldBalances(requests)
yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = tonAndSolanaIds)
}
coVerify(inverse = true) { yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) }
val expected = ApiResponseError.NetworkException
Truth.assertThat(actual.isLeft()).isTrue()
Truth.assertThat(actual.leftOrNull()).isInstanceOf(expected::class.java)
Truth.assertThat(actual.leftOrNull()).hasMessageThat().isEqualTo(expected.message)
}
private companion object {
val userWalletId = UserWalletId("011")
val userWallet = MockUserWalletFactory.create()
val mocks = MockCryptoCurrencyFactory()
val ton = mocks.createCoin(Blockchain.TON)
val solana = mocks.createCoin(Blockchain.Solana)
val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId
val solanaId = StakingID(
integrationId = "solana-sol-native-multivalidator-staking",
address = "0x1",
)
val tonAndSolanaIds = setOf(tonId, solanaId)
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.data.staking.single
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams
import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher
import com.tangem.domain.staking.single.SingleYieldBalanceFetcher
import com.tangem.domain.wallets.models.UserWalletId
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
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultSingleYieldBalanceFetcherV2Test {
private val multiYieldBalanceFetcher: MultiYieldBalanceFetcher = mockk()
private val fetcher = DefaultSingleYieldBalanceFetcherV2(
multiYieldBalanceFetcher = multiYieldBalanceFetcher,
)
@BeforeEach
fun resetMocks() {
clearMocks(multiYieldBalanceFetcher)
}
@Test
fun `fetch yield balance successfully`() = runTest {
// Arrange
val params = SingleYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
val multiParams = YieldBalanceFetcherParams.Multi(
userWalletId = userWalletId,
currencyIdWithNetworkMap = mapOf(ton.id to ton.network),
)
val multiResult = Unit.right()
coEvery { multiYieldBalanceFetcher(params = multiParams) } returns multiResult
// Act
val actual = fetcher.invoke(params).isRight()
// Assert
Truth.assertThat(actual).isTrue()
coVerify { multiYieldBalanceFetcher(params = multiParams) }
}
@Test
fun `fetch yield balance failure`() = runTest {
// Arrange
val params = SingleYieldBalanceFetcher.Params(
userWalletId = userWalletId,
currencyId = ton.id,
network = ton.network,
)
val multiParams = YieldBalanceFetcherParams.Multi(
userWalletId = userWalletId,
currencyIdWithNetworkMap = mapOf(ton.id to ton.network),
)
val multiResult = IllegalStateException().left()
coEvery { multiYieldBalanceFetcher(params = multiParams) } returns multiResult
// Act
val actual = fetcher.invoke(params)
// Assert
Truth.assertThat(actual).isEqualTo(multiResult)
coVerify { multiYieldBalanceFetcher(params = multiParams) }
}
private companion object {
val userWalletId = UserWalletId("011")
val ton = MockCryptoCurrencyFactory().createCoin(Blockchain.TON)
}
}

View file

@ -502,7 +502,8 @@ internal class DefaultCurrenciesRepository(
currencyRawId: CryptoCurrency.RawID,
): Flow<Map<UserWallet, List<CryptoCurrency>>> {
return userWalletsStore.userWallets.flatMapLatest { userWallets ->
userWallets.forEach { fetchTokensIfCacheExpired(userWallet = it, refresh = false) }
userWallets.filter { it.isMultiCurrency }
.forEach { fetchTokensIfCacheExpired(userWallet = it, refresh = false) }
val userWalletsWithCurrencies = userWallets
.filterNot(UserWallet::isLocked)