Updated on 2026-08-14

This commit is contained in:
Tangem 2025-06-23 16:37:57 +04:00
parent b9bb9e1086
commit 058a6ab4d5
15 changed files with 969 additions and 256 deletions

View file

@ -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)
}
}

View file

@ -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<RequestParams, Mutex>()
/**
* 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<String, Set<QuoteMetadata>>()
override suspend fun fetch(
fiatCurrencyId: String,
currenciesIds: Set<String>,
fields: Set<Field>,
): Either<Error, QuotesResponse> = 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<Error>.validateParams(
fiatCurrencyId: String,
currenciesIds: Set<String>,
fields: Set<Field>,
): 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<Error>.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<Error>.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<String>.filterExpiredOrAbsent(quotes: Set<QuoteMetadata>?): List<String> {
return filter { id ->
val quote = quotes?.firstOrNull { it.cryptoCurrencyId == id && !it.isExpired }
quote == null
}
}
private fun Raise<Error>.getCachedResult(fiatCurrencyId: String, currenciesIds: Set<String>): 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<Error>.requestQuotes(
fiatCurrencyId: String,
currenciesIds: Set<String>,
fields: Set<Field>,
) = 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<String>,
val fields: Set<Field>,
)
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<QuoteMetadata>) {
quotesCache[fiatCurrencyId] = quotes
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun clearCache() {
quotesCache.clear()
}
private companion object {
val emptyQuotesResponse = QuotesResponse(quotes = emptyMap())
val tenSecInMillis = 10.seconds.inWholeMilliseconds
}
}

View file

@ -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 <a href = "https://www.notion.so/tangem/Quotes-21b5d34eb6788038af9ccee37c7db7f9">Documentation<a/>
*/
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<String>,
fields: Set<Field>,
): Either<Error, QuotesResponse>
/**
* 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<Error, QuotesResponse> {
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
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.data.common.quote.utils
import com.tangem.data.common.quote.QuotesFetcher
fun Set<QuotesFetcher.Field>.combine(): String = joinToString(separator = ",", transform = QuotesFetcher.Field::value)

View file

@ -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<TangemTechApi>()
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<String, Set<QuoteMetadata>>()
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<String, Set<QuoteMetadata>>()
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<String, Set<QuoteMetadata>>()
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<QuoteMetadata>?.toResponseQuotes() = this!!.associate { it.cryptoCurrencyId to it.value }
}