diff --git a/common/test/build.gradle.kts b/common/test/build.gradle.kts index 52931ce5f3..d0c4c8fd00 100644 --- a/common/test/build.gradle.kts +++ b/common/test/build.gradle.kts @@ -33,4 +33,5 @@ dependencies { implementation(tangemDeps.card.core) implementation(deps.test.junit5) + implementation(deps.test.truth) } \ No newline at end of file diff --git a/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt b/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt new file mode 100644 index 0000000000..d28fe3df7e --- /dev/null +++ b/common/test/src/main/java/com/tangem/common/test/utils/TruthExt.kt @@ -0,0 +1,14 @@ +package com.tangem.common.test.utils + +import arrow.core.Either +import com.google.common.truth.Truth + +fun assertEither(actual: Either, expected: Either) { + actual + .onRight { Truth.assertThat(it).isEqualTo(expected) } + .onLeft { + val expectedError = expected.leftOrNull() ?: error("Expected must be Either.Left") + + Truth.assertThat(it::class.java).isEqualTo(expectedError::class.java) + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 0ac0fdc78a..a6793286f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -65,7 +65,7 @@ interface TangemTechApi { suspend fun getQuotes( @Query("currencyId") currencyId: String, @Query("coinIds") coinIds: String, - @Query("fields") fields: String = "price,priceChange24h,lastUpdatedAt", + @Query("fields") fields: String, ): ApiResponse @GET("promotion") @@ -176,13 +176,4 @@ interface TangemTechApi { @GET("user-wallets/wallets/by-app/{app_id}") suspend fun getWallets(@Path("app_id") appId: String): ApiResponse> // endregion - - companion object { - val marketsQuoteFields = listOf( - "price", - "priceChange24h", - "priceChange1w", - "priceChange30d", - ) - } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt index f443f4bd8a..6ceeb800e9 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/di/DataCommonModule.kt @@ -5,6 +5,8 @@ import com.tangem.data.common.currency.CardCryptoCurrencyFactory import com.tangem.data.common.currency.DefaultCardCryptoCurrencyFactory import com.tangem.data.common.currency.UserTokensResponseAddressesEnricher import com.tangem.data.common.currency.UserTokensSaver +import com.tangem.data.common.quote.DefaultQuotesFetcher +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.token.UserTokensResponseStore @@ -70,4 +72,10 @@ internal object DataCommonModule { userTokensResponseAddressesEnricher = enricher, ) } + + @Provides + @Singleton + fun provideQuotesFetcher(tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider): QuotesFetcher { + return DefaultQuotesFetcher(tangemTechApi = tangemTechApi, dispatchers = dispatchers) + } } \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcher.kt b/data/common/src/main/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcher.kt new file mode 100644 index 0000000000..ca867be571 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcher.kt @@ -0,0 +1,240 @@ +package com.tangem.data.common.quote + +import androidx.annotation.VisibleForTesting +import arrow.core.Either +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.ensure +import arrow.core.raise.ensureNotNull +import com.tangem.data.common.api.safeApiCallWithTimeout +import com.tangem.data.common.quote.QuotesFetcher.Error +import com.tangem.data.common.quote.QuotesFetcher.Field +import com.tangem.data.common.quote.utils.combine +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.domain.core.utils.eitherOn +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.joda.time.DateTime +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.seconds + +/** + * Default implementation of [QuotesFetcher] + * + * @property tangemTechApi Tangem tech API + * @property dispatchers dispatchers + * +[REDACTED_AUTHOR] + */ +internal class DefaultQuotesFetcher( + private val tangemTechApi: TangemTechApi, + private val dispatchers: CoroutineDispatcherProvider, +) : QuotesFetcher { + + /** Mutex for synchronization of the [getFetchMutex] method */ + private val getFetchMethodMutex = Mutex() + + /** + * Map for tracking active [fetch] methods. + * The key is the parameters of the [fetch] method, and the value is [Mutex]. + */ + private val fetchMutexMap = ConcurrentHashMap() + + /** + * Cache that stores a list of quotes for a certain fiat currency. + * Quotes are considered expired if they are in the cache for more than [tenSecInMillis] seconds. + */ + private val quotesCache = ConcurrentHashMap>() + + override suspend fun fetch( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ): Either = eitherOn(dispatchers.default) { + val validatedParams = validateParams(fiatCurrencyId, currenciesIds, fields) + + if (validatedParams.currenciesIds.isEmpty()) return@eitherOn emptyQuotesResponse + + val mutex = getFetchMutex(params = validatedParams) + + return@eitherOn fetch(params = validatedParams, mutex = mutex) + } + .onLeft { + val params = RequestParams(fiatCurrencyId, currenciesIds, fields) + fetchMutexMap.remove(params) + } + + private fun Raise.validateParams( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ): RequestParams { + ensure(fiatCurrencyId.isNotBlank() && fields.isNotEmpty()) { + raise(Error.InvalidArgumentsError) + } + + val filterCurrenciesIds = currenciesIds.filter(String::isNotEmpty).toSet() + + return RequestParams(fiatCurrencyId = fiatCurrencyId, currenciesIds = filterCurrenciesIds, fields = fields) + } + + /** + * The method for determining the [Mutex], which will be used by the [fetch] method. + * + * @param params request params + * + * @return if at the moment the method of [fetch] is already executed for an adjacent set of parameters + + */ + private suspend fun Raise.getFetchMutex(params: RequestParams): Mutex { + return getFetchMethodMutex.withLock { + val similarJobsMutexes = fetchMutexMap.filterKeys { metadata -> + metadata.fiatCurrencyId == params.fiatCurrencyId && + params.currenciesIds.any { it in metadata.currenciesIds } + } + + val storedMutex = similarJobsMutexes.firstNotNullOfOrNull { it.value } + + when { + storedMutex == null || !storedMutex.isLocked -> Mutex() + storedMutex.isLocked -> storedMutex + else -> raise(Error.CacheOperationError) + } + .also { fetchMutexMap[params] = it } + } + } + + /** + * Fetch quotes by [params]. + * + * It works in the scope of the transferred [mutex]. + * If at the moment [Mutex] is busy, then it is working on fetching related data, then the current request will + * wait for its execution. + * + * @return [QuotesResponse] + */ + private suspend fun Raise.fetch(params: RequestParams, mutex: Mutex): QuotesResponse { + val (fiatCurrencyId, currenciesIds) = params + + return mutex.withLock { + val quotes = quotesCache[fiatCurrencyId].orEmpty() + + val expiredOrAbsentIds = currenciesIds.filterExpiredOrAbsent(quotes) + + // We will fetch quotes only for those quotes that are absent in cache or expired + if (expiredOrAbsentIds.isNotEmpty()) { + val response = requestQuotes( + fiatCurrencyId = fiatCurrencyId, + currenciesIds = expiredOrAbsentIds.toSet(), + fields = params.fields, + ) + + saveQuotes(fiatCurrencyId = fiatCurrencyId, response = response) + } + + fetchMutexMap.remove(params) + + getCachedResult(fiatCurrencyId = fiatCurrencyId, currenciesIds = currenciesIds) + } + } + + private fun Set.filterExpiredOrAbsent(quotes: Set?): List { + return filter { id -> + val quote = quotes?.firstOrNull { it.cryptoCurrencyId == id && !it.isExpired } + + quote == null + } + } + + private fun Raise.getCachedResult(fiatCurrencyId: String, currenciesIds: Set): QuotesResponse { + val storedQuotes = quotesCache[fiatCurrencyId] + + ensureNotNull(storedQuotes) { + raise(Error.CacheOperationError) + } + + val quotes = catch( + block = { + currenciesIds.associateWith { currencyId -> + storedQuotes.first { it.cryptoCurrencyId == currencyId }.value + } + }, + catch = { raise(Error.CacheOperationError) }, + ) + + return QuotesResponse(quotes = quotes) + } + + private suspend fun Raise.requestQuotes( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ) = withContext(dispatchers.io) { + safeApiCallWithTimeout( + call = { + tangemTechApi.getQuotes( + currencyId = fiatCurrencyId, + coinIds = currenciesIds.joinToString(separator = ","), + fields = fields.combine(), + ) + .bind() + }, + onError = { raise(Error.ApiOperationError(it)) }, + ) + } + + private fun saveQuotes(fiatCurrencyId: String, response: QuotesResponse) { + val newQuotes = response.quotes.mapTo(destination = hashSetOf()) { (currencyId, quote) -> + QuoteMetadata( + cryptoCurrencyId = currencyId, + timestamp = DateTime.now().millis, + value = quote, + ) + } + + val storedQuotes = quotesCache[fiatCurrencyId].orEmpty() + + val storedUniqueQuotes = storedQuotes.filterNot { stored -> + newQuotes.any { stored.cryptoCurrencyId == it.cryptoCurrencyId } + } + + quotesCache[fiatCurrencyId] = (storedUniqueQuotes + newQuotes).toSet() + } + + data class RequestParams( + val fiatCurrencyId: String, + val currenciesIds: Set, + val fields: Set, + ) + + data class QuoteMetadata( + val cryptoCurrencyId: String, + val timestamp: Long, + val value: QuotesResponse.Quote, + ) { + + val isExpired: Boolean + get() = DateTime.now().millis - timestamp > tenSecInMillis + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun getCachedQuotes() = quotesCache + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun setCachedQuotes(fiatCurrencyId: String, quotes: Set) { + quotesCache[fiatCurrencyId] = quotes + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun clearCache() { + quotesCache.clear() + } + + private companion object { + val emptyQuotesResponse = QuotesResponse(quotes = emptyMap()) + val tenSecInMillis = 10.seconds.inWholeMilliseconds + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/quote/QuotesFetcher.kt b/data/common/src/main/kotlin/com/tangem/data/common/quote/QuotesFetcher.kt new file mode 100644 index 0000000000..4f1e8b92ce --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/quote/QuotesFetcher.kt @@ -0,0 +1,61 @@ +package com.tangem.data.common.quote + +import arrow.core.Either +import com.tangem.data.common.quote.utils.combine +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.tangemTech.models.QuotesResponse + +/** + * Fetcher of quotes + * +[REDACTED_AUTHOR] + * + * @see Documentation + */ +interface QuotesFetcher { + + /** + * Fetch + * + * @param fiatCurrencyId fiat currency id + * @param currenciesIds crypto currencies ids + * @param fields fields of [QuotesResponse.Quote] + */ + suspend fun fetch( + fiatCurrencyId: String, + currenciesIds: Set, + fields: Set, + ): Either + + /** + * Fetch + * + * @param fiatCurrencyId fiat currency id + * @param currencyId crypto currencies ids + * @param field fields of [QuotesResponse.Quote] + */ + suspend fun fetch(fiatCurrencyId: String, currencyId: String, field: Field): Either { + return fetch(fiatCurrencyId = fiatCurrencyId, currenciesIds = setOf(currencyId), fields = setOf(field)) + } + + enum class Field(internal val value: String) { + PRICE(value = "price"), + PRICE_CHANGE_24H(value = "priceChange24h"), + PRICE_CHANGE_1W(value = "priceChange1w"), + PRICE_CHANGE_30D(value = "priceChange30d"), + ALL_PRICES( + value = setOf(PRICE, PRICE_CHANGE_24H, PRICE_CHANGE_1W, PRICE_CHANGE_30D).combine(), + ), + LAST_UPDATED_AT(value = "lastUpdatedAt"), + ; + } + + sealed interface Error { + + data object InvalidArgumentsError : Error + + data object CacheOperationError : Error + + data class ApiOperationError(val apiError: ApiResponseError) : Error + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/quote/utils/FieldExt.kt b/data/common/src/main/kotlin/com/tangem/data/common/quote/utils/FieldExt.kt new file mode 100644 index 0000000000..e7ea17d255 --- /dev/null +++ b/data/common/src/main/kotlin/com/tangem/data/common/quote/utils/FieldExt.kt @@ -0,0 +1,5 @@ +package com.tangem.data.common.quote.utils + +import com.tangem.data.common.quote.QuotesFetcher + +fun Set.combine(): String = joinToString(separator = ",", transform = QuotesFetcher.Field::value) \ No newline at end of file diff --git a/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt new file mode 100644 index 0000000000..add4f12f94 --- /dev/null +++ b/data/common/src/test/kotlin/com/tangem/data/common/quote/DefaultQuotesFetcherTest.kt @@ -0,0 +1,444 @@ +package com.tangem.data.common.quote + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.data.common.quote.DefaultQuotesFetcher.QuoteMetadata +import com.tangem.data.common.quote.QuotesFetcher.Field +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.QuotesResponse +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap + +/** +[REDACTED_AUTHOR] + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultQuotesFetcherTest { + + private val tangemTechApi = mockk() + private val fetcher = DefaultQuotesFetcher( + tangemTechApi = tangemTechApi, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun resetMocks() { + clearMocks(tangemTechApi) + fetcher.clearCache() + } + + @Test + fun `fetch if fiatCurrencyId is EMPTY`() = runTest { + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesFetcher.Error.InvalidArgumentsError.left() + Truth.assertThat(actual).isEqualTo(expected) + + val expectedCacheData = ConcurrentHashMap>() + Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if currenciesIds is EMPTY`() = runTest { + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = emptySet(), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = emptyMap()).right() + Truth.assertThat(actual).isEqualTo(expected) + + val expectedCacheData = ConcurrentHashMap>() + Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if fields is EMPTY`() = runTest { + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = emptySet(), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesFetcher.Error.InvalidArgumentsError.left() + Truth.assertThat(actual).isEqualTo(expected) + + val expectedCacheData = ConcurrentHashMap>() + Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if all currencies ids ARE CACHED and ARE NOT EXPIRED`() = runTest { + // Arrange + val quote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "ethereum", + timestamp = DateTime.now().millis, + value = quote, + ), + ), + ) + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to quote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to quote)) + + coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) } + } + + @Test + fun `fetch if all currencies ids ARE CACHED and EXPIRED`() = runTest { + // Arrange + val cachedQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "ethereum", + timestamp = DateTime.now().millis - 10_000, + value = cachedQuote, + ), + ), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to apiQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `fetch if cache contain EXPIRED and NOT EXPIRED quotes`() = runTest { + // Arrange + val cachedQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO) + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "ethereum", + timestamp = DateTime.now().millis - 10_000, + value = cachedQuote, + ), + QuoteMetadata( + cryptoCurrencyId = "bitcoin", + timestamp = DateTime.now().millis, + value = cachedQuote, + ), + ), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum", "bitcoin"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse( + quotes = mapOf("ethereum" to apiQuote, "bitcoin" to cachedQuote), + ).right() + + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()) + .isEqualTo(mapOf("ethereum" to apiQuote, "bitcoin" to cachedQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `fetch if cache DOES NOT CONTAIN fiat currency`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes(fiatCurrencyId = "eu", quotes = emptySet()) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(2) + Truth.assertThat(actualCacheData["eu"]).isEmpty() + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to apiQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `fetch if cached quotes are ABSENT`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + val apiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + fetcher.setCachedQuotes(fiatCurrencyId = "usd", quotes = emptySet()) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns apiResponse + + // Act + val actual = fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + Truth.assertThat(actual).isEqualTo(expected) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()) + .isEqualTo(mapOf("ethereum" to apiQuote)) + + coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") } + } + + @Test + fun `two parallel fetch if cache is empty`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + val usdApiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)), + ) + + val euApiResponse = ApiResponse.Success( + data = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote)), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + } returns usdApiResponse + + coEvery { + tangemTechApi.getQuotes(currencyId = "eu", coinIds = "bitcoin", fields = "price") + } returns euApiResponse + + // Act + val actual = listOf( + async { + fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum"), + fields = setOf(Field.PRICE), + ) + }, + async { + fetcher.fetch( + fiatCurrencyId = "eu", + currenciesIds = setOf("bitcoin"), + fields = setOf(Field.PRICE), + ) + }, + ) + .awaitAll() + + val actual1 = actual[0] + val actual2 = actual[1] + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected1 = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right() + val expected2 = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote)).right() + Truth.assertThat(actual1).isEqualTo(expected1) + Truth.assertThat(actual2).isEqualTo(expected2) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(2) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()) + .isEqualTo(mapOf("ethereum" to apiQuote)) + Truth.assertThat(actualCacheData["eu"].toResponseQuotes()) + .isEqualTo(mapOf("bitcoin" to apiQuote)) + + coVerify(exactly = 1) { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") + tangemTechApi.getQuotes(currencyId = "eu", coinIds = "bitcoin", fields = "price") + } + } + + @Test + fun `two parallel fetch`() = runTest { + // Arrange + val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE) + + val firstApiResponse = ApiResponse.Success( + data = QuotesResponse( + quotes = mapOf("ethereum" to apiQuote, "solana" to apiQuote), + ), + ) + + val secondApiResponse = ApiResponse.Success( + data = QuotesResponse( + quotes = mapOf("bitcoin" to apiQuote, "solana" to apiQuote), + ), + ) + + fetcher.setCachedQuotes( + fiatCurrencyId = "usd", + quotes = setOf( + QuoteMetadata( + cryptoCurrencyId = "solana", + timestamp = DateTime.now().millis - 10_000, + value = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO), + ), + ), + ) + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum,solana", fields = "price") + } returns firstApiResponse + + coEvery { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "bitcoin", fields = "price") + } returns secondApiResponse + + // Act + val actual = listOf( + async { + fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("ethereum", "solana"), + fields = setOf(Field.PRICE), + ) + }, + async { + fetcher.fetch( + fiatCurrencyId = "usd", + currenciesIds = setOf("bitcoin", "solana"), + fields = setOf(Field.PRICE), + ) + }, + ) + .awaitAll() + + val actual1 = actual[0] + val actual2 = actual[1] + + val actualCacheData = fetcher.getCachedQuotes() + + // Assert + val expected1 = QuotesResponse(quotes = mapOf("ethereum" to apiQuote, "solana" to apiQuote)).right() + val expected2 = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote, "solana" to apiQuote)).right() + Truth.assertThat(actual1).isEqualTo(expected1) + Truth.assertThat(actual2).isEqualTo(expected2) + + Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1) + Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo( + mapOf("ethereum" to apiQuote, "bitcoin" to apiQuote, "solana" to apiQuote), + ) + + coVerifyOrder { + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum,solana", fields = "price") + tangemTechApi.getQuotes(currencyId = "usd", coinIds = "bitcoin", fields = "price") + } + } + + private fun Iterable?.toResponseQuotes() = this!!.associate { it.cryptoCurrencyId to it.value } +} \ No newline at end of file diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index cba7c2bf69..f6d9ebbfbd 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -1,5 +1,6 @@ package com.tangem.data.markets +import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.compatibility.applyL2Compatibility import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network @@ -9,6 +10,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.common.utils.retryOnError import com.tangem.data.markets.analytics.MarketsDataAnalyticsEvent import com.tangem.data.markets.converters.* @@ -16,8 +18,6 @@ import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.markets.models.response.TokenMarketExchangesResponse -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.TangemTechApi.Companion.marketsQuoteFields import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.markets.* @@ -37,7 +37,7 @@ import java.util.concurrent.atomic.AtomicLong @Suppress("LongParameterList") internal class DefaultMarketsTokenRepository( private val marketsApi: TangemTechMarketsApi, - private val tangemTechApi: TangemTechApi, + private val quotesFetcher: QuotesFetcher, private val userWalletsStore: UserWalletsStore, private val dispatcherProvider: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, @@ -112,7 +112,7 @@ internal class DefaultMarketsTokenRepository( nextBatchSize: Int, ): BatchFlow, TokenMarketUpdateRequest> { val tokenMarketsUpdateFetcher = MarketsBatchUpdateFetcher( - tangemTechApi = tangemTechApi, + quotesFetcher = quotesFetcher, marketsApi = marketsApi, analyticsEventHandler = analyticsEventHandler, onApiResponseError = { @@ -225,16 +225,24 @@ internal class DefaultMarketsTokenRepository( withContext(dispatcherProvider.io) { // for second markets iteration we should use extended api method with all required fields - val result = catchDetailsErrorAndSendEvent( - request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, - tokenSymbol = tokenSymbol, - ) { - tangemTechApi.getQuotes( - currencyId = fiatCurrencyCode, - coinIds = tokenId.value, - fields = marketsQuoteFields.joinToString(separator = ","), - ).getOrThrow() - } + val result = quotesFetcher.fetch( + fiatCurrencyId = fiatCurrencyCode, + currencyId = tokenId.value, + field = QuotesFetcher.Field.ALL_PRICES, + ) + .getOrElse { + val error = it as QuotesFetcher.Error.ApiOperationError + + val errorEvent = createDetailsErrorEvent( + error = error.apiError, + request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, + tokenSymbol = tokenSymbol, + ) + + analyticsEventHandler.send(errorEvent.toEvent()) + + throw error.apiError + } return@withContext TokenQuotesShortConverter.convert(tokenId, result).toFull() } diff --git a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt index 3b5e662379..82ab261728 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/MarketsBatchUpdateFetcher.kt @@ -1,6 +1,8 @@ package com.tangem.data.markets +import arrow.core.getOrElse import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.common.utils.retryOnError import com.tangem.data.markets.analytics.MarketsDataAnalyticsEvent import com.tangem.data.markets.converters.TokenMarketChartsConverter @@ -11,8 +13,6 @@ import com.tangem.datasource.api.common.response.catchApiResponseError import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.markets.TangemTechMarketsApi import com.tangem.datasource.api.markets.models.response.TokenMarketChartListResponse -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.TangemTechApi.Companion.marketsQuoteFields import com.tangem.domain.markets.TokenMarket import com.tangem.domain.markets.TokenMarketUpdateRequest import com.tangem.pagination.Batch @@ -24,7 +24,7 @@ import kotlinx.coroutines.launch internal class MarketsBatchUpdateFetcher( private val marketsApi: TangemTechMarketsApi, - private val tangemTechApi: TangemTechApi, + private val quotesFetcher: QuotesFetcher, private val analyticsEventHandler: AnalyticsEventHandler, private val onApiResponseError: (ApiResponseError) -> Unit, ) : BatchUpdateFetcher, TokenMarketUpdateRequest> { @@ -72,13 +72,24 @@ internal class MarketsBatchUpdateFetcher( } is TokenMarketUpdateRequest.UpdateQuotes -> { val quotesRes = retryOnError { - catchApiResponseError(onApiResponseError) { - tangemTechApi.getQuotes( - currencyId = updateRequest.currencyId, - coinIds = idsToUpdate.map { it.second }.flatten().joinToString(separator = ","), - fields = marketsQuoteFields.joinToString(separator = ","), - ).getOrThrow() - } + val currenciesIds = idsToUpdate.flatMapTo(hashSetOf()) { it.second.map { rawID -> rawID.value } } + + quotesFetcher.fetch( + fiatCurrencyId = updateRequest.currencyId, + currenciesIds = currenciesIds, + fields = setOf(QuotesFetcher.Field.ALL_PRICES), + ) + .getOrElse { + val exception = if (it is QuotesFetcher.Error.ApiOperationError) { + onApiResponseError(it.apiError) + + it.apiError + } else { + error("Cause: $it") + } + + throw exception + } } update { diff --git a/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt b/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt index 1c1830b9cc..da2ddeb1b7 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/di/MarketsDataModule.kt @@ -4,9 +4,9 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.common.cache.CacheRegistry import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.markets.DefaultMarketsTokenRepository import com.tangem.datasource.api.markets.TangemTechMarketsApi -import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.datastore.RuntimeStateStore import com.tangem.datasource.local.userwallet.UserWalletsStore import com.tangem.domain.markets.repositories.MarketsTokenRepository @@ -25,7 +25,7 @@ internal object MarketsDataModule { @Singleton fun provideMarketsTokenRepository( marketsApi: TangemTechMarketsApi, - tangemTechApi: TangemTechApi, + quotesFetcher: QuotesFetcher, userWalletsStore: UserWalletsStore, dispatchers: CoroutineDispatcherProvider, analyticsEventHandler: AnalyticsEventHandler, @@ -35,7 +35,7 @@ internal object MarketsDataModule { ): MarketsTokenRepository { return DefaultMarketsTokenRepository( marketsApi = marketsApi, - tangemTechApi = tangemTechApi, + quotesFetcher = quotesFetcher, dispatcherProvider = dispatchers, userWalletsStore = userWalletsStore, analyticsEventHandler = analyticsEventHandler, diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt index c605e43574..618f75178d 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcher.kt @@ -1,18 +1,18 @@ package com.tangem.data.quotes.multi import arrow.core.Either -import com.tangem.data.common.api.safeApiCallWithTimeout +import arrow.core.getOrElse +import com.tangem.data.common.quote.QuotesFetcher +import com.tangem.data.common.quote.QuotesFetcher.Field import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.data.quotes.store.setSourceAsCache import com.tangem.data.quotes.store.setSourceAsOnlyCache import com.tangem.data.quotes.utils.QuotesUnsupportedCurrenciesIdAdapter -import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -20,16 +20,16 @@ import javax.inject.Singleton /** * Default implementation of [MultiQuoteStatusFetcher] * - * @property tangemTechApi tangemTech api + * @property quotesFetcher quotes fetcher * @property appCurrencyResponseStore app currency response store - * @property quotesStatusesStore quotes store + * @property quotesStatusesStore quotes statuses store * @property dispatchers dispatchers * [REDACTED_AUTHOR] */ @Singleton internal class DefaultMultiQuoteStatusFetcher @Inject constructor( - private val tangemTechApi: TangemTechApi, + private val quotesFetcher: QuotesFetcher, private val appCurrencyResponseStore: AppCurrencyResponseStore, private val quotesStatusesStore: QuotesStatusesStore, private val dispatchers: CoroutineDispatcherProvider, @@ -51,16 +51,13 @@ internal class DefaultMultiQuoteStatusFetcher @Inject constructor( ) val appCurrencyId = getAppCurrencyId(params = params) - val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",") - val response = safeApiCallWithTimeout( - call = { - withContext(dispatchers.io) { - tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds).bind() - } - }, - onError = { error -> throw error }, + val response = quotesFetcher.fetch( + fiatCurrencyId = appCurrencyId, + currenciesIds = replacementIdsResult.idsForRequest, + fields = setOf(Field.PRICE, Field.PRICE_CHANGE_24H), ) + .getOrElse { error("Cause: $it") } val updatedResponse = QuotesUnsupportedCurrenciesIdAdapter.getResponseWithUnsupportedCurrencies( response = response, diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt index 4db5f02c4f..d522ca5ba9 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/multi/DefaultMultiQuoteStatusFetcherTest.kt @@ -1,107 +1,125 @@ package com.tangem.data.quotes.multi +import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth import com.tangem.common.test.data.quote.MockQuoteResponseFactory +import com.tangem.common.test.utils.assertEither +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.quotes.store.QuotesStatusesStore import com.tangem.data.quotes.store.setSourceAsCache import com.tangem.data.quotes.store.setSourceAsOnlyCache -import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse import com.tangem.datasource.api.tangemTech.models.QuotesResponse import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.coVerifyOrder -import io.mockk.mockk +import io.mockk.* import kotlinx.coroutines.test.runTest -import org.junit.Test +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance import java.math.BigDecimal /** [REDACTED_AUTHOR] */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class DefaultMultiQuoteStatusFetcherTest { - private val tangemTechApi = mockk(relaxed = true) - private val appCurrencyResponseStore = mockk(relaxed = true) + private val quotesFetcher = mockk() + private val appCurrencyResponseStore = mockk() private val quotesStore = mockk(relaxed = true) private val fetcher = DefaultMultiQuoteStatusFetcher( - tangemTechApi = tangemTechApi, + quotesFetcher = quotesFetcher, appCurrencyResponseStore = appCurrencyResponseStore, quotesStatusesStore = quotesStore, dispatchers = TestingCoroutineDispatcherProvider(), ) + @BeforeEach + fun resetMocks() { + clearMocks(quotesFetcher, appCurrencyResponseStore, quotesStore) + } + @Test - fun `fetch quotes successfully`() = runTest { + fun `fetch successfully`() = runTest { + // Arrange val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - val coinIds = "BTC,ETH" - coEvery { - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - } returns ApiResponse.Success(successResponse) + val currenciesIds = setOf("BTC", "ETH") + coEvery { + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) + } returns successResponse.right() + + // Act val actual = fetcher(params) + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + coVerifyOrder { quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) - appCurrencyResponseStore.getSyncOrNull() - - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) quotesStore.store(values = successResponse.quotes) } coVerify(inverse = true) { quotesStore.setSourceAsOnlyCache(currenciesIds = any()) } - - Truth.assertThat(actual.isRight()).isTrue() } @Test - fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest { + fun `fetch successfully if currenciesIds from params is empty`() = runTest { + // Arrange val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null) + // Act val actual = fetcher(params) + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + coVerify(inverse = true) { quotesStore.setSourceAsCache(currenciesIds = any()) appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any()) quotesStore.store(values = any()) quotesStore.setSourceAsOnlyCache(currenciesIds = any()) } - - Truth.assertThat(actual.isRight()).isTrue() } @Test - fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest { + fun `fetch successfully if appCurrencyId from params is not null`() = runTest { + // Arrange val appCurrencyId = "usd" val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) - val coinIds = "BTC,ETH" - coEvery { - tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds) - } returns ApiResponse.Success(successResponse) + val currenciesIds = setOf("BTC", "ETH") + coEvery { + quotesFetcher.fetch(fiatCurrencyId = appCurrencyId, currenciesIds = currenciesIds, fields = fields) + } returns successResponse.right() + + // Act val actual = fetcher(params) + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) + coVerifyOrder { quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) - - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - + quotesFetcher.fetch(fiatCurrencyId = appCurrencyId, currenciesIds = currenciesIds, fields = fields) quotesStore.store(values = successResponse.quotes) } @@ -109,17 +127,22 @@ internal class DefaultMultiQuoteStatusFetcherTest { appCurrencyResponseStore.getSyncOrNull() quotesStore.setSourceAsOnlyCache(currenciesIds = any()) } - - Truth.assertThat(actual.isRight()).isTrue() } @Test - fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest { + fun `fetch failure because appCurrencyId from params is blank`() = runTest { + // Arrange val appCurrencyId = "" val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId) + // Act val actual = fetcher(params) + // Assert + val expected = IllegalStateException("Unable to get AppCurrency for updating quotes").left() + + assertEither(actual, expected) + coVerifyOrder { quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) @@ -127,55 +150,59 @@ internal class DefaultMultiQuoteStatusFetcherTest { coVerify(inverse = true) { appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any()) quotesStore.store(values = any()) } - - Truth.assertThat(actual.isLeft()).isTrue() - Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java) - Truth.assertThat(actual.leftOrNull()).hasMessageThat() - .isEqualTo("Unable to get AppCurrency for updating quotes") } @Test - fun `fetch quotes failure because api request failed`() = runTest { + fun `fetch failure because api request failed`() = runTest { + // Arrange val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) + val currenciesIds = setOf("BTC", "ETH") + val error = QuotesFetcher.Error.ApiOperationError(ApiResponseError.NetworkException) + coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - val coinIds = "BTC,ETH" - - @Suppress("UNCHECKED_CAST") - val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse - coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse + coEvery { + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) + } returns error.left() + // Act val actual = fetcher(params) + // Assert + val expected = IllegalStateException("Cause: ApiOperationError(apiError=NetworkException)").left() + assertEither(actual, expected) + coVerifyOrder { quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) - appCurrencyResponseStore.getSyncOrNull() - - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - + quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields) quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) } coVerify(inverse = true) { quotesStore.store(values = any()) } - - Truth.assertThat(actual.isLeft()).isTrue() } @Test - fun `fetch quotes failure because app currency not found`() = runTest { + fun `fetch failure because app currency not found`() = runTest { + // Arrange val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null) coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null + // Act val actual = fetcher(params) + // Assert + val expected = IllegalStateException("Unable to get AppCurrency for updating quotes").left() + + assertEither(actual, expected) + coVerifyOrder { quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds) appCurrencyResponseStore.getSyncOrNull() @@ -183,11 +210,9 @@ internal class DefaultMultiQuoteStatusFetcherTest { } coVerify(inverse = true) { - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) + quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any()) quotesStore.store(values = any()) } - - Truth.assertThat(actual.isLeft()).isTrue() } private companion object { @@ -212,5 +237,7 @@ internal class DefaultMultiQuoteStatusFetcherTest { "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN), ), ) + + val fields = setOf(QuotesFetcher.Field.PRICE, QuotesFetcher.Field.PRICE_CHANGE_24H) } } \ No newline at end of file diff --git a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcherTest.kt b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcherTest.kt index 245d0364b9..c13faf11c6 100644 --- a/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcherTest.kt +++ b/data/quotes/src/test/java/com/tangem/data/quotes/single/DefaultSingleQuoteStatusFetcherTest.kt @@ -1,176 +1,79 @@ package com.tangem.data.quotes.single +import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth -import com.tangem.common.test.data.quote.MockQuoteResponseFactory -import com.tangem.data.quotes.multi.DefaultMultiQuoteStatusFetcher -import com.tangem.data.quotes.store.QuotesStatusesStore -import com.tangem.data.quotes.store.setSourceAsCache -import com.tangem.data.quotes.store.setSourceAsOnlyCache -import com.tangem.datasource.api.common.response.ApiResponse -import com.tangem.datasource.api.common.response.ApiResponseError -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse -import com.tangem.datasource.api.tangemTech.models.QuotesResponse -import com.tangem.datasource.appcurrency.AppCurrencyResponseStore import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.coVerifyOrder import io.mockk.mockk import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import java.math.BigDecimal +import org.junit.jupiter.api.TestInstance +@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class DefaultSingleQuoteStatusFetcherTest { - private val tangemTechApi = mockk(relaxed = true) - private val appCurrencyResponseStore = mockk(relaxed = true) - private val quotesStore = mockk(relaxed = true) - - private val multiFetcher = DefaultMultiQuoteStatusFetcher( - tangemTechApi = tangemTechApi, - appCurrencyResponseStore = appCurrencyResponseStore, - quotesStatusesStore = quotesStore, - dispatchers = TestingCoroutineDispatcherProvider(), - ) - + private val multiFetcher = mockk() private val singleFetcher = DefaultSingleQuoteStatusFetcher(multiFetcher) - @Test - fun `fetch single quote successfully`() = runTest { - val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - - val coinIds = "BTC" - coEvery { - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - } returns ApiResponse.Success(successResponse) - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.setSourceAsCache(currenciesIds = setOf(params.rawCurrencyId)) - appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - quotesStore.store(values = successResponse.quotes) - } - - coVerify(inverse = true) { - quotesStore.setSourceAsOnlyCache(currenciesIds = any()) - } - - Truth.assertThat(actual.isRight()).isTrue() + @BeforeEach + fun resetMocks() { + clearMocks(multiFetcher) } @Test - fun `fetch single quote successfully if appCurrencyId from params is not null`() = runTest { - val appCurrencyId = "usd" - val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId) + fun `fetch successfully if multiFetcher returns success`() = runTest { + // Arrange + val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currencyId, appCurrencyId = null) - val coinIds = "BTC" - coEvery { - tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds) - } returns ApiResponse.Success(successResponse) + val multiFetcherParams = MultiQuoteStatusFetcher.Params( + currenciesIds = setOf(params.rawCurrencyId), + appCurrencyId = params.appCurrencyId, + ) + coEvery { multiFetcher(multiFetcherParams) } returns Unit.right() + + // Act val actual = singleFetcher(params) - coVerifyOrder { - quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId)) - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - quotesStore.store(values = successResponse.quotes) - } + // Assert + val expected = Unit.right() + Truth.assertThat(actual).isEqualTo(expected) - Truth.assertThat(actual.isRight()).isTrue() + coVerify(exactly = 1) { multiFetcher(multiFetcherParams) } } @Test - fun `fetch single quote failure because appCurrencyId from params is blank`() = runTest { - val appCurrencyId = "" - val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId) + fun `fetch failure if multiFetcher returns erro`() = runTest { + // Arrange + val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currencyId, appCurrencyId = null) + val multiFetcherParams = MultiQuoteStatusFetcher.Params( + currenciesIds = setOf(params.rawCurrencyId), + appCurrencyId = params.appCurrencyId, + ) + + val multiFetcherError = IllegalStateException("").left() + + coEvery { multiFetcher(multiFetcherParams) } returns multiFetcherError + + // Act val actual = singleFetcher(params) - coVerifyOrder { - quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId)) - quotesStore.setSourceAsOnlyCache(currenciesIds = setOf(currenciesId)) - } + // Assert + val expected = multiFetcherError + Truth.assertThat(actual).isEqualTo(expected) - Truth.assertThat(actual.isLeft()).isTrue() - Truth.assertThat(actual.leftOrNull()).isInstanceOf(IllegalStateException::class.java) - Truth.assertThat(actual.leftOrNull()).hasMessageThat() - .isEqualTo("Unable to get AppCurrency for updating quotes") - } - - @Test - fun `fetch single quote failure because api request failed`() = runTest { - val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency - - val coinIds = "BTC" - - @Suppress("UNCHECKED_CAST") - val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse - coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId)) - appCurrencyResponseStore.getSyncOrNull() - tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) - quotesStore.setSourceAsOnlyCache(currenciesIds = setOf(currenciesId)) - } - - coVerify(inverse = true) { - quotesStore.store(values = any()) - } - - Truth.assertThat(actual.isLeft()).isTrue() - } - - @Test - fun `fetch single quote failure because app currency not found`() = runTest { - val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null) - - coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null - - val actual = singleFetcher(params) - - coVerifyOrder { - quotesStore.setSourceAsCache(currenciesIds = setOf(currenciesId)) - appCurrencyResponseStore.getSyncOrNull() - quotesStore.setSourceAsOnlyCache(currenciesIds = setOf(currenciesId)) - } - - coVerify(inverse = true) { - tangemTechApi.getQuotes(currencyId = any(), coinIds = any()) - quotesStore.store(values = any()) - } - - Truth.assertThat(actual.isLeft()).isTrue() + coVerify(exactly = 1) { multiFetcher(multiFetcherParams) } } private companion object { - val currenciesId = CryptoCurrency.RawID(value = "BTC") - - val usdAppCurrency = CurrenciesResponse.Currency( - id = "USD".lowercase(), - code = "USD", - name = "US Dollar", - unit = "$", - type = "fiat", - rateBTC = "", - ) - - val successResponse = QuotesResponse( - quotes = mapOf( - "BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE), - ), - ) + val currencyId = CryptoCurrency.RawID(value = "BTC") } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt index 3e3faade06..09eaf90d10 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/DefaultVisaRepository.kt @@ -3,6 +3,7 @@ package com.tangem.data.visa import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import arrow.core.getOrElse import arrow.fx.coroutines.parZip import com.tangem.blockchain.common.address.Address import com.tangem.blockchain.common.address.AddressType @@ -10,10 +11,9 @@ import com.tangem.common.card.EllipticCurve import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toHexString import com.tangem.data.common.cache.CacheRegistry +import com.tangem.data.common.quote.QuotesFetcher import com.tangem.data.visa.config.VisaLibLoader import com.tangem.data.visa.utils.* -import com.tangem.datasource.api.common.response.getOrThrow -import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.visa.TangemVisaApi import com.tangem.datasource.api.visa.models.response.VisaTxHistoryResponse import com.tangem.datasource.local.userwallet.UserWalletsStore @@ -38,7 +38,7 @@ import javax.inject.Singleton @Singleton internal class DefaultVisaRepository @Inject constructor( private val visaLibLoader: VisaLibLoader, - private val tangemTechApi: TangemTechApi, + private val quotesFetcher: QuotesFetcher, private val cacheRegistry: CacheRegistry, private val userWalletsStore: UserWalletsStore, private val dispatchers: CoroutineDispatcherProvider, @@ -190,10 +190,13 @@ internal class DefaultVisaRepository @Inject constructor( private suspend fun getFiatRate(): BigDecimal { val fiatCurrencyId = VisaConstants.fiatCurrency.code.lowercase() - val quotes = tangemTechApi.getQuotes( - currencyId = fiatCurrencyId, - coinIds = VisaConstants.TOKEN_ID, - ).getOrThrow() + + val quotes = quotesFetcher.fetch( + fiatCurrencyId = fiatCurrencyId, + currencyId = VisaConstants.TOKEN_ID, + field = QuotesFetcher.Field.PRICE, + ) + .getOrElse { error("Cause: $it") } return quotes.quotes[VisaConstants.TOKEN_ID]?.price ?: error("No price found") }