diff --git a/data/staking/build.gradle.kts b/data/staking/build.gradle.kts index b4d8282fac..6ca20ef8a8 100644 --- a/data/staking/build.gradle.kts +++ b/data/staking/build.gradle.kts @@ -13,6 +13,10 @@ android { namespace = "com.tangem.data.staking" } +tasks.withType().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) diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2.kt new file mode 100644 index 0000000000..89fd57cdb6 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2.kt @@ -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 { + 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): Set { + 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 { + 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) { + 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, + yieldBalances: Set, + ): Boolean { + return requests.all { request -> + yieldBalances.any { + request.integrationId == it.integrationId && + request.addresses.address == it.addresses.address + } + } + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2.kt b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2.kt new file mode 100644 index 0000000000..03b04083c1 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2.kt @@ -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 { + return multiYieldBalanceFetcher( + params = YieldBalanceFetcherParams.Multi( + userWalletId = params.userWalletId, + currencyIdWithNetworkMap = mapOf( + params.currencyId to params.network, + ), + ), + ) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2Test.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2Test.kt new file mode 100644 index 0000000000..4899543e29 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherV2Test.kt @@ -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>()) + 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> + + 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) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2Test.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2Test.kt new file mode 100644 index 0000000000..0455f8bc2b --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherV2Test.kt @@ -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) + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt index a189e8bcc9..70d96d7b6e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt @@ -1,11 +1,26 @@ package com.tangem.domain.staking.multi import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.wallets.models.UserWalletId /** * Fetcher of yields balances * [REDACTED_AUTHOR] */ -interface MultiYieldBalanceFetcher : FlowFetcher \ No newline at end of file +interface MultiYieldBalanceFetcher : FlowFetcher { + + /** + * Params for fetching multiple yield balances + * + * @property userWalletId user wallet ID + * @property currencyIdWithNetworkMap map of currency ID to network + */ + data class Params( + val userWalletId: UserWalletId, + val currencyIdWithNetworkMap: Map, + ) +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt index 1b81d24be3..603ab4c73a 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt @@ -1,11 +1,28 @@ package com.tangem.domain.staking.single import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.wallets.models.UserWalletId /** * Fetcher of yield balance * [REDACTED_AUTHOR] */ -interface SingleYieldBalanceFetcher : FlowFetcher \ No newline at end of file +interface SingleYieldBalanceFetcher : FlowFetcher { + + /** + * Params for fetching single yield balance + * + * @property userWalletId user wallet ID + * @property currencyId currency ID + * @property network network + */ + data class Params( + val userWalletId: UserWalletId, + val currencyId: CryptoCurrency.ID, + val network: Network, + ) +} \ No newline at end of file