Updated on 2026-08-14

This commit is contained in:
Tangem 2025-04-05 12:19:37 +04:00
parent 14c20117eb
commit a398620b0d
14 changed files with 477 additions and 4 deletions

1
data/quotes/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,30 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.quotes"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(deps.androidx.datastore)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
}

View file

@ -0,0 +1,89 @@
package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.quote.converter.QuoteConverter
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
/**
* Default implementation of [QuotesStoreV2]
*
* @property runtimeStore runtime store
* @property persistenceDataStore persistence store
* @param dispatchers dispatchers
*/
internal class DefaultQuotesStoreV2(
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
dispatchers: CoroutineDispatcherProvider,
) : QuotesStoreV2 {
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
init {
scope.launch {
val cachedStatuses = persistenceDataStore.data.firstOrNull()
if (cachedStatuses.isNullOrEmpty()) return@launch
runtimeStore.store(
value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries),
)
}
}
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
override suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>) {
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.CACHE)
}
override suspend fun storeActual(values: Map<String, QuotesResponse.Quote>) {
coroutineScope {
launch {
val quotes = QuoteConverter(isCached = false).convertSet(input = values.entries)
storeInRuntimeStore(values = quotes)
}
launch { storeInPersistenceStore(values = values) }
}
}
override suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>) {
updateStatusSourceInRuntime(currenciesIds = currenciesIds, source = StatusSource.ONLY_CACHE)
}
private suspend fun updateStatusSourceInRuntime(currenciesIds: Set<CryptoCurrency.RawID>, source: StatusSource) {
runtimeStore.update(default = emptySet()) { stored ->
val updatedQuotes = currenciesIds.mapTo(hashSetOf()) { id ->
val quote = stored.firstOrNull { it.rawCurrencyId == id } ?: Quote.Empty(id)
quote.copySealed(source = source)
}
stored.addOrReplace(items = updatedQuotes) { old, new -> old.rawCurrencyId == new.rawCurrencyId }
}
}
private suspend fun storeInRuntimeStore(values: Set<Quote>) {
runtimeStore.update(default = emptySet()) { saved ->
saved.addOrReplace(items = values) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
}
}
private suspend fun storeInPersistenceStore(values: Map<String, QuotesResponse.Quote>) {
persistenceDataStore.updateData { storedQuotes -> storedQuotes + values }
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.data.quotes.store
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import kotlinx.coroutines.flow.Flow
/** Store of [Quote]'es set */
internal interface QuotesStoreV2 {
/** Get flow of quotes */
fun get(): Flow<Set<Quote>>
/** Refresh status of [currenciesIds] */
suspend fun refresh(currenciesIds: Set<CryptoCurrency.RawID>)
/** Store actual map of currency ids and quotes [values] */
suspend fun storeActual(values: Map<String, QuotesResponse.Quote>)
/** Store error for [currenciesIds] */
suspend fun storeError(currenciesIds: Set<CryptoCurrency.RawID>)
}

View file

@ -0,0 +1,63 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreGetMethodTest {
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
private val store = DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `test get if runtime store is empty`() = runTest {
val actual = store.get()
val values = backgroundScope.getEmittedValues(testScheduler, actual)
Truth.assertThat(values).isEqualTo(emptyList<Set<Quote>>())
}
@Test
fun `test get if runtime store contains empty set`() = runTest {
runtimeStore.store(value = emptySet())
val actual = store.get()
val values = backgroundScope.getEmittedValues(testScheduler, actual)
Truth.assertThat(values).isEqualTo(listOf(emptySet<Quote>()))
}
@Test
fun `test get if runtime store is not empty`() = runTest {
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
runtimeStore.store(value = setOf(btcQuote.toDomain(), ethQuote.toDomain()))
val actual = store.get()
val values = backgroundScope.getEmittedValues(testScheduler, actual)
Truth.assertThat(values.size).isEqualTo(1)
Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain())))
}
}

View file

@ -0,0 +1,82 @@
package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreInitializationTest {
@Test
fun `test initialization if cache store is empty`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
every { persistenceStore.data } returns emptyFlow()
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store contains empty map`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(null)
}
@Test
fun `test initialization if cache store is not empty`() = runTest {
val runtimeStore = RuntimeSharedStore<Set<Quote>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
val btcQuote = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val ethQuote = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
persistenceStore.updateData {
it.toMutableMap().apply {
this += btcQuote
this += ethQuote
}
}
DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
val expected = setOf(
btcQuote.toDomain(source = StatusSource.CACHE),
ethQuote.toDomain(source = StatusSource.CACHE),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(expected)
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
internal class QuotesStoreUpdateMethodsTest {
private val runtimeStore = RuntimeSharedStore<Set<Quote>>()
private val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
private val store = DefaultQuotesStoreV2(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@Test
fun `refresh if runtime store is empty`() = runTest {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
store.refresh(currenciesIds = currenciesIds)
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `refresh if runtime store contains quote with this id`() = runTest {
val quote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL)
runtimeStore.store(value = setOf(quote))
store.refresh(currenciesIds = setOf(quote.rawCurrencyId))
val runtimeExpected = setOf(quote.copySealed(source = StatusSource.CACHE))
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `store actual if runtime and cache stores contain quotes with this id`() = runTest {
val prevStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
runtimeStore.store(
value = setOf(
prevStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
),
)
persistenceStore.updateData {
it.toMutableMap().apply {
put("BTC", prevStatus)
}
}
val newStatus = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN)
store.storeActual(values = mapOf("BTC" to newStatus))
val runtimeExpected = setOf(
newStatus.toDomain(rawCurrencyId = "BTC", source = StatusSource.ACTUAL),
)
val persistenceExpected = mapOf("BTC" to newStatus)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(persistenceExpected)
}
@Test
fun `store error if runtime store is empty`() = runTest {
val currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
)
store.storeError(currenciesIds = currenciesIds)
val runtimeExpected = currenciesIds.map(Quote::Empty).toSet()
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
@Test
fun `store error if runtime store contains status with this network`() = runTest {
val status = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
runtimeStore.store(
value = setOf(
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.CACHE),
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
),
)
store.storeError(
currenciesIds = setOf(
CryptoCurrency.RawID(value = "BTC"),
CryptoCurrency.RawID(value = "ETH"),
),
)
val runtimeExpected = setOf(
status.toDomain(rawCurrencyId = "BTC", source = StatusSource.ONLY_CACHE),
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<Quote>>())
}
}