diff --git a/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt new file mode 100644 index 0000000000..189705d948 --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/data/staking/MockYieldDTOFactory.kt @@ -0,0 +1,88 @@ +package com.tangem.common.test.data.staking + +import com.tangem.datasource.api.stakekit.models.response.model.AddressArgumentDTO +import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO +import com.tangem.domain.staking.model.StakingID +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +object MockYieldDTOFactory { + + val defaultStakingID = StakingID( + integrationId = "ton-ton-chorus-one-pools-staking", + address = "0x1", + ) + + fun create(stakingID: StakingID = defaultStakingID): YieldDTO { + return YieldDTO( + id = stakingID.integrationId, + token = TokenDTO( + name = "Miguel Estes", + network = NetworkTypeDTO.POLYGON, + symbol = "splendide", + decimals = 1323, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + tokens = listOf(), + args = YieldDTO.ArgsDTO( + enter = YieldDTO.ArgsDTO.Enter( + addresses = YieldDTO.ArgsDTO.Enter.Addresses( + address = AddressArgumentDTO(required = false), + ), + args = mapOf(), + ), + exit = null, + ), + status = YieldDTO.StatusDTO(enter = true, exit = true), + apy = BigDecimal.ONE, + rewardRate = 1.0, + rewardType = YieldDTO.RewardTypeDTO.UNKNOWN, + metadata = YieldDTO.MetadataDTO( + name = "name", + logoUri = "logoUri", + description = "description", + documentation = null, + gasFeeTokenDTO = TokenDTO( + name = "Johnnie Mullen", + network = NetworkTypeDTO.POLYGON, + symbol = "fuisset", + decimals = 2957, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + tokenDTO = TokenDTO( + name = "Lazaro Wood", + network = NetworkTypeDTO.POLYGON, + symbol = "vocent", + decimals = 1602, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + tokensDTO = listOf(), + type = "type", + rewardSchedule = YieldDTO.MetadataDTO.RewardScheduleDTO.DAY, + cooldownPeriod = null, + warmupPeriod = YieldDTO.MetadataDTO.PeriodDTO(1), + rewardClaiming = YieldDTO.MetadataDTO.RewardClaimingDTO.AUTO, + defaultValidator = null, + minimumStake = null, + supportsMultipleValidators = null, + revshare = YieldDTO.MetadataDTO.EnabledDTO(enabled = true), + fee = YieldDTO.MetadataDTO.EnabledDTO(enabled = true), + ), + validators = listOf(), + isAvailable = true, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/fetcher/CommonYieldBalanceFetcherDelegate.kt b/data/staking/src/main/java/com/tangem/data/staking/fetcher/CommonYieldBalanceFetcherDelegate.kt new file mode 100644 index 0000000000..7204495961 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/fetcher/CommonYieldBalanceFetcherDelegate.kt @@ -0,0 +1,114 @@ +package com.tangem.data.staking.fetcher + +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 com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.datasource.api.stakekit.models.response.model.YieldDTO +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.core.utils.catchOn +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import timber.log.Timber + +internal fun commonFetcher( + implementor: YieldBalanceFetcherImplementor, + stakingYieldsStore: StakingYieldsStore, + yieldsBalancesStore: YieldsBalancesStore, + dispatchers: CoroutineDispatcherProvider, +): FlowFetcher { + return CommonYieldBalanceFetcher( + implementor = implementor, + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) +} + +/** + * Common implementation of YieldBalanceFetcher + * + * @param implementor fetcher implementor + * @property stakingYieldsStore staking yields store + * @property yieldsBalancesStore yields balances store + * @property dispatchers dispatchers + */ +private class CommonYieldBalanceFetcher( + private val implementor: YieldBalanceFetcherImplementor, + private val stakingYieldsStore: StakingYieldsStore, + private val yieldsBalancesStore: YieldsBalancesStore, + private val dispatchers: CoroutineDispatcherProvider, +) : FlowFetcher { + + override suspend fun invoke(params: Params): Either { + val stakingIds = getStakingIds(params).getOrElse { + return it.left() + } + + return Either.catchOn(dispatchers.default) { + val requests = prefetch(userWalletId = params.userWalletId, stakingIds = stakingIds) + + implementor.fetch(params = params, stakingIds = stakingIds, requests) + } + .onLeft { yieldsBalancesStore.storeError(userWalletId = params.userWalletId, stakingIds = stakingIds) } + } + + private suspend fun getStakingIds(params: Params): Either> = either { + val stakingIds = catch( + block = { implementor.createStakingIds(params = params) }, + catch = { raise(it) }, + ) + + 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 prefetch( + userWalletId: UserWalletId, + stakingIds: Set, + ): List { + yieldsBalancesStore.refresh(userWalletId = userWalletId, stakingIds = stakingIds) + + val yieldDTOs = stakingYieldsStore.getSyncWithTimeout() + + if (yieldDTOs.isNullOrEmpty()) { + val exception = IllegalStateException("No enabled yields for $userWalletId") + Timber.e(exception) + throw exception + } + + val yieldIds = yieldDTOs.mapNotNullTo(destination = hashSetOf(), transform = YieldDTO::id) + + val requests = stakingIds + .filter { stakingId -> yieldIds.any { it == stakingId.integrationId } } + .map(YieldBalanceRequestBodyFactory::create) + + if (requests.isEmpty()) { + val exception = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${stakingIds.joinToString()} + """.trimIndent(), + ) + Timber.d(exception) + throw exception + } + + return requests + } +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/fetcher/YieldBalanceFetcherImplementor.kt b/data/staking/src/main/java/com/tangem/data/staking/fetcher/YieldBalanceFetcherImplementor.kt index 27835a3da0..be258fc5fe 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/fetcher/YieldBalanceFetcherImplementor.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/fetcher/YieldBalanceFetcherImplementor.kt @@ -9,10 +9,10 @@ import com.tangem.domain.staking.model.StakingID * [REDACTED_AUTHOR] */ -internal interface YieldBalanceFetcherImplementor { +internal interface YieldBalanceFetcherImplementor { /** Create set of [StakingID] */ - suspend fun createStakingIds(params: @UnsafeVariance Params): Set + suspend fun createStakingIds(params: Params): Set /** * Fetch yield balances @@ -21,9 +21,5 @@ internal interface YieldBalanceFetcherImplementor, - requests: List, - ) + suspend fun fetch(params: Params, stakingIds: Set, requests: List) } \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt new file mode 100644 index 0000000000..45cccaea38 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcher.kt @@ -0,0 +1,51 @@ +package com.tangem.data.staking.multi + +import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor +import com.tangem.data.staking.fetcher.commonFetcher +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * Default implementation of [MultiYieldBalanceFetcher] + * + * @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 DefaultMultiYieldBalanceFetcher( + private val stakingYieldsStore: StakingYieldsStore, + private val yieldsBalancesStore: YieldsBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitApi: StakeKitApi, + private val dispatchers: CoroutineDispatcherProvider, +) : MultiYieldBalanceFetcher, + FlowFetcher by commonFetcher( + implementor = createMultiFetcherImplementor(yieldsBalancesStore, stakingIdFactory, stakeKitApi, dispatchers), + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + +private fun createMultiFetcherImplementor( + yieldsBalancesStore: YieldsBalancesStore, + stakingIdFactory: StakingIdFactory, + stakeKitApi: StakeKitApi, + dispatchers: CoroutineDispatcherProvider, +): YieldBalanceFetcherImplementor { + return MultiYieldBalanceFetcherImplementor( + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = dispatchers, + ) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcher.kt b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcher.kt new file mode 100644 index 0000000000..37d48ac768 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcher.kt @@ -0,0 +1,52 @@ +package com.tangem.data.staking.single + +import com.tangem.data.staking.fetcher.YieldBalanceFetcherImplementor +import com.tangem.data.staking.fetcher.commonFetcher +import com.tangem.data.staking.store.YieldsBalancesStore +import com.tangem.data.staking.utils.StakingIdFactory +import com.tangem.datasource.api.stakekit.StakeKitApi +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.multi.MultiYieldBalanceFetcher +import com.tangem.domain.staking.single.SingleYieldBalanceFetcher +import com.tangem.utils.coroutines.CoroutineDispatcherProvider + +/** + * Default implementation of [MultiYieldBalanceFetcher] + * + * @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 DefaultSingleYieldBalanceFetcher( + private val stakingYieldsStore: StakingYieldsStore, + private val yieldsBalancesStore: YieldsBalancesStore, + private val stakingIdFactory: StakingIdFactory, + private val stakeKitApi: StakeKitApi, + private val dispatchers: CoroutineDispatcherProvider, +) : SingleYieldBalanceFetcher, + FlowFetcher by commonFetcher( + implementor = createSingleFetcherImplementor(yieldsBalancesStore, stakingIdFactory, stakeKitApi, dispatchers), + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + dispatchers = dispatchers, + ) + +private fun createSingleFetcherImplementor( + yieldsBalancesStore: YieldsBalancesStore, + stakingIdFactory: StakingIdFactory, + stakeKitApi: StakeKitApi, + dispatchers: CoroutineDispatcherProvider, +): YieldBalanceFetcherImplementor { + return SingleYieldBalanceFetcherImplementor( + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = dispatchers, + ) +} \ No newline at end of file diff --git a/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt b/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt new file mode 100644 index 0000000000..64d63950a9 --- /dev/null +++ b/data/staking/src/main/java/com/tangem/data/staking/utils/YieldBalanceRequestBodyFactory.kt @@ -0,0 +1,27 @@ +package com.tangem.data.staking.utils + +import com.tangem.datasource.api.stakekit.models.request.Address +import com.tangem.datasource.api.stakekit.models.request.YieldBalanceRequestBody +import com.tangem.domain.staking.model.StakingID + +/** + * Factory for creating [YieldBalanceRequestBody] + * +[REDACTED_AUTHOR] + */ +internal object YieldBalanceRequestBodyFactory { + + fun create(stakingID: StakingID): YieldBalanceRequestBody { + return YieldBalanceRequestBody( + addresses = Address( + address = stakingID.address, + additionalAddresses = null, // todo fill additional addresses metadata if needed + explorerUrl = "", // todo fill exporer url [REDACTED_JIRA] + ), + args = YieldBalanceRequestBody.YieldBalanceRequestArgs( + validatorAddresses = listOf(), // todo add validators [REDACTED_JIRA] + ), + integrationId = stakingID.integrationId, + ) + } +} \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt new file mode 100644 index 0000000000..5f57b9d20d --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/multi/DefaultMultiYieldBalanceFetcherTest.kt @@ -0,0 +1,323 @@ +package com.tangem.data.staking.multi + +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.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.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultMultiYieldBalanceFetcherTest { + + private val stakingYieldsStore: StakingYieldsStore = mockk() + private val yieldsBalancesStore: YieldsBalancesStore = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + private val stakeKitApi: StakeKitApi = mockk() + + private val fetcher = DefaultMultiYieldBalanceFetcher( + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `fetch yields balances successfully`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + 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 + + val actual = fetcher(params) + + coVerify { + 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 failure if stakingIdFactory returns empty list`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns emptySet() + coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns emptySet() + + val actual = fetcher(params) + + coVerify { + 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 { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + 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 + + val actual = fetcher(params) + + coVerify { + 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 { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + 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 + + val actual = fetcher(params) + + coVerify { + 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 { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + 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 + + val actual = fetcher(params) + + coVerify { + 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 available yields does not contain ids from params`() = runTest { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + 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 + + val actual = fetcher(params) + + coVerify { + 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 { + val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network) + + val params = YieldBalanceFetcherParams.Multi(userWalletId, currencyIdWithNetworkMap) + + 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 + + val actual = fetcher(params) + + coVerify { + 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 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/DefaultSingleYieldBalanceFetcherTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherTest.kt new file mode 100644 index 0000000000..16e2049744 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/single/DefaultSingleYieldBalanceFetcherTest.kt @@ -0,0 +1,363 @@ +package com.tangem.data.staking.single + +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.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.request.YieldBalanceRequestBody +import com.tangem.datasource.api.stakekit.models.response.model.BalanceDTO +import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO +import com.tangem.datasource.api.stakekit.models.response.model.TokenDTO +import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO +import com.tangem.datasource.local.token.StakingYieldsStore +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams +import com.tangem.domain.staking.model.StakingID +import com.tangem.domain.wallets.models.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.math.BigDecimal + +/** +[REDACTED_AUTHOR] + */ +internal class DefaultSingleYieldBalanceFetcherTest { + + private val stakingYieldsStore: StakingYieldsStore = mockk() + private val yieldsBalancesStore: YieldsBalancesStore = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() + private val stakeKitApi: StakeKitApi = mockk() + + private val fetcher = DefaultSingleYieldBalanceFetcher( + stakingYieldsStore = stakingYieldsStore, + yieldsBalancesStore = yieldsBalancesStore, + stakingIdFactory = stakingIdFactory, + stakeKitApi = stakeKitApi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @Test + fun `fetch yields balances successfully`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + + val request = YieldBalanceRequestBodyFactory.create(tonId) + val result = listOf(createBalanceDTO()) + coEvery { stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) } returns ApiResponse.Success(result) + + val values = result.mapTo(hashSetOf()) { it.toWrapper(request) } + coEvery { yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = values) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getSingleYieldBalance(integrationId = tonId.integrationId, body = request) + yieldsBalancesStore.storeActual(userWalletId = userWalletId, values = values) + } + + coVerify(inverse = true) { yieldsBalancesStore.storeError(any(), any()) } + + Truth.assertThat(actual.isRight()).isTrue() + } + + @Test + fun `fetch yields balances failure if stakingIdFactory createForDefault returns null`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns null + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(userWalletId = userWalletId, currencyId = ton.id, network = ton.network) + } + + coVerify(inverse = true) { + 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("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 { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = 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 { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList() + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = 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 { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId).copy(id = null)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${setOf(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 available yields does not contain ids from params`() = runTest { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1"))) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) + } + + coVerify(inverse = true) { + stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any()) + yieldsBalancesStore.storeActual(userWalletId = any(), values = any()) + } + + val expected = IllegalStateException( + """ + No available yields to fetch yield balances: + – userWalletId: $userWalletId + – stakingIds: ${setOf(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 { + val params = YieldBalanceFetcherParams.Single( + userWalletId = userWalletId, + currencyId = ton.id, + network = ton.network, + ) + + coEvery { stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) } returns tonId + coEvery { + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + } just Runs + + val yields = listOf(MockYieldDTOFactory.create(tonId)) + coEvery { stakingYieldsStore.getSyncWithTimeout() } returns yields + + val request = YieldBalanceRequestBodyFactory.create(tonId) + + @Suppress("UNCHECKED_CAST") + val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse> + + coEvery { stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) } returns errorResponse + coEvery { yieldsBalancesStore.storeError(userWalletId, setOf(tonId)) } just Runs + + val actual = fetcher(params) + + coVerify { + stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network) + yieldsBalancesStore.refresh(userWalletId = params.userWalletId, stakingIds = setOf(tonId)) + stakingYieldsStore.getSyncWithTimeout() + stakeKitApi.getSingleYieldBalance(tonId.integrationId, request) + yieldsBalancesStore.storeError(userWalletId = userWalletId, stakingIds = setOf(tonId)) + } + + 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 fun createBalanceDTO(): BalanceDTO { + return BalanceDTO( + groupId = "dictas", + type = BalanceDTO.BalanceTypeDTO.REWARDS, + amount = BigDecimal.ONE, + date = null, + pricePerShare = BigDecimal.ONE, + pendingActions = listOf(), + pendingActionConstraints = listOf(), + tokenDTO = TokenDTO( + name = "Casandra Paul", + network = NetworkTypeDTO.POLYGON, + symbol = "vim", + decimals = 3994, + address = null, + coinGeckoId = null, + logoURI = null, + isPoints = null, + ), + validatorAddress = null, + validatorAddresses = listOf(), + providerId = null, + ) + } + + private fun BalanceDTO.toWrapper(request: YieldBalanceRequestBody): YieldBalanceWrapperDTO { + return YieldBalanceWrapperDTO( + balances = listOf(this), + integrationId = request.integrationId, + addresses = request.addresses, + ) + } + + private companion object { + val userWalletId = UserWalletId("011") + + val mocks = MockCryptoCurrencyFactory() + + val ton = mocks.createCoin(Blockchain.TON) + + val tonId = MockYieldBalanceWrapperDTOFactory.defaultStakingId + } +} \ 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 new file mode 100644 index 0000000000..a189e8bcc9 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiYieldBalanceFetcher.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.staking.multi + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams + +/** + * Fetcher of yields balances + * +[REDACTED_AUTHOR] + */ +interface MultiYieldBalanceFetcher : FlowFetcher \ 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 new file mode 100644 index 0000000000..1b81d24be3 --- /dev/null +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleYieldBalanceFetcher.kt @@ -0,0 +1,11 @@ +package com.tangem.domain.staking.single + +import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.staking.fetcher.YieldBalanceFetcherParams + +/** + * Fetcher of yield balance + * +[REDACTED_AUTHOR] + */ +interface SingleYieldBalanceFetcher : FlowFetcher \ No newline at end of file