Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-19 14:55:23 +04:00
parent c4e357a6bc
commit 851d82631e
29 changed files with 264 additions and 107 deletions

View file

@ -0,0 +1,20 @@
package com.tangem.data.quotes.converter
import com.tangem.data.quotes.store.QuoteStatusDM
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.utils.converter.TwoWayConverter
/**
* Two-way converter between domain [FiatCurrency] and persisted [QuoteStatusDM.FiatCurrency].
*
* - [convert] domain DM (for persistence).
* - [convertBack] DM domain (for restore on cold start).
*/
internal object FiatCurrencyConverter : TwoWayConverter<FiatCurrency, QuoteStatusDM.FiatCurrency> {
override fun convert(value: FiatCurrency): QuoteStatusDM.FiatCurrency =
QuoteStatusDM.FiatCurrency(code = value.code, symbol = value.symbol)
override fun convertBack(value: QuoteStatusDM.FiatCurrency): FiatCurrency =
FiatCurrency(code = value.code, symbol = value.symbol)
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.quotes.converter
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
@ -10,12 +11,14 @@ import com.tangem.utils.extensions.orZero
/**
* Converter from [QuotesResponse.Quote] to [QuoteStatus]
*
* @property source status source
* @property source status source
* @property fiatCurrency fiat currency in which the quote is expressed
*
[REDACTED_AUTHOR]
*/
internal class QuoteStatusConverter(
private val source: StatusSource,
private val fiatCurrency: FiatCurrency,
) : Converter<Map.Entry<String, QuotesResponse.Quote>, QuoteStatus> {
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): QuoteStatus {
@ -25,6 +28,7 @@ internal class QuoteStatusConverter(
rawCurrencyId = CryptoCurrency.RawID(currencyId),
value = QuoteStatus.Data(
source = source,
fiatCurrency = fiatCurrency,
fiatRate = quote.price.orZero(),
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
fiatRateUSD = quote.priceUsd.orZero(),

View file

@ -4,16 +4,16 @@ import android.content.Context
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater
import com.tangem.data.quotes.repository.DefaultQuotesRepository
import com.tangem.data.quotes.store.DefaultQuotesStatusesStore
import com.tangem.data.quotes.store.QuoteStatusDM
import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
@ -30,6 +30,7 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
internal object QuotesDataModule {
@OptIn(ExperimentalStdlibApi::class)
@Singleton
@Provides
fun provideQuotesStoreV2(
@ -41,13 +42,13 @@ internal object QuotesDataModule {
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
moshi = moshi,
types = mapWithStringKeyTypes<QuotesResponse.Quote>(),
defaultValue = emptyMap(),
defaultValue = QuoteStatusDM.Empty,
adapter = moshi.adapter<QuoteStatusDM>(),
),
produceFile = { context.dataStoreFile(fileName = "quotes") },
produceFile = { context.dataStoreFile(fileName = "quotes_v2") },
scope = appScope,
),
legacyCacheFile = context.dataStoreFile(fileName = "quotes"),
scope = appScope,
)
}

View file

@ -8,9 +8,11 @@ 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.models.CurrenciesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
@ -50,10 +52,10 @@ internal class DefaultMultiQuoteStatusFetcher @Inject constructor(
),
)
val appCurrencyId = getAppCurrencyId(params = params)
val (fiatCurrencyId, fiatCurrency) = resolveFiatCurrency()
val response = quotesFetcher.fetch(
fiatCurrencyId = appCurrencyId,
fiatCurrencyId = fiatCurrencyId,
currenciesIds = replacementIdsResult.idsForRequest,
fields = setOf(Field.PRICE, Field.PRICE_CHANGE_24H, Field.PRICE_USD),
)
@ -64,24 +66,27 @@ internal class DefaultMultiQuoteStatusFetcher @Inject constructor(
filteredIds = replacementIdsResult.idsFiltered,
)
quotesStatusesStore.store(values = updatedResponse.quotes)
quotesStatusesStore.store(values = updatedResponse.quotes, fiatCurrency = fiatCurrency)
}
.onLeft { throwable ->
TangemLogger.e("Error", throwable)
quotesStatusesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
}
private suspend fun getAppCurrencyId(params: MultiQuoteStatusFetcher.Params): String {
val appCurrencyId = params.appCurrencyId
?: appCurrencyResponseStore.getSyncOrNull()?.id
private suspend fun resolveFiatCurrency(): Pair<String, FiatCurrency> {
val stored = appCurrencyResponseStore.getSyncOrNull() ?: failOnMissingAppCurrency()
if (stored.id.isBlank()) failOnMissingAppCurrency()
if (appCurrencyId.isNullOrBlank()) {
val exception = IllegalStateException("Unable to get AppCurrency for updating quotes")
TangemLogger.e("Error", exception)
return stored.id to stored.toFiatCurrency()
}
throw exception
}
private fun failOnMissingAppCurrency(): Nothing {
val exception = IllegalStateException("Unable to get AppCurrency for updating quotes")
TangemLogger.e("Error", exception)
throw exception
}
return appCurrencyId
private fun CurrenciesResponse.Currency.toFiatCurrency(): FiatCurrency {
return FiatCurrency(code = code, symbol = unit)
}
}

View file

@ -1,11 +1,13 @@
package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.tangem.data.quotes.converter.FiatCurrencyConverter
import com.tangem.data.quotes.converter.QuoteStatusConverter
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.extensions.addOrReplace
@ -14,6 +16,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import java.io.File
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
@ -21,27 +24,46 @@ internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
* Default implementation of [QuotesStatusesStore]
*
* @property runtimeStore runtime store
* @property persistenceDataStore persistence store
* @param dispatchers dispatchers
* @property persistenceDataStore persistence store (keeps quotes together with the fiat currency they're expressed in)
* @property legacyCacheFile pre-v2 cache file kept on disk; deleted once on init
* @param scope app coroutine scope
*/
internal class DefaultQuotesStatusesStore(
private val runtimeStore: RuntimeSharedStore<Set<QuoteStatus>>,
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
private val persistenceDataStore: DataStore<QuoteStatusDM>,
private val legacyCacheFile: File,
private val scope: AppCoroutineScope,
) : QuotesStatusesStore {
init {
scope.launch {
val cachedStatuses = persistenceDataStore.data.firstOrNull()
deleteLegacyCacheFile()
if (cachedStatuses.isNullOrEmpty()) return@launch
val cached = persistenceDataStore.data.firstOrNull() ?: return@launch
val fiatCurrency = cached.fiatCurrency?.let(FiatCurrencyConverter::convertBack) ?: return@launch
if (cached.quotes.isEmpty()) return@launch
runtimeStore.store(
value = QuoteStatusConverter(source = StatusSource.CACHE).convertSet(input = cachedStatuses.entries),
value = QuoteStatusConverter(source = StatusSource.CACHE, fiatCurrency = fiatCurrency)
.convertSet(input = cached.quotes.entries),
)
}
}
private fun deleteLegacyCacheFile() {
if (!legacyCacheFile.exists()) return
runCatching { legacyCacheFile.delete() }
.onSuccess { deleted ->
if (deleted) {
TangemLogger.i("Deleted legacy quotes cache file: ${legacyCacheFile.name}")
} else {
TangemLogger.e("Could not delete legacy quotes cache file: ${legacyCacheFile.name}")
}
}
.onFailure { TangemLogger.e("Failed to delete legacy quotes cache file", it) }
}
override fun get(): Flow<Set<QuoteStatus>> = runtimeStore.get()
override suspend fun getAllSyncOrNull(): Set<QuoteStatus>? = runtimeStore.getSyncOrNull()
@ -81,24 +103,33 @@ internal class DefaultQuotesStatusesStore(
}
}
override suspend fun store(values: CurrencyIdWithQuote) {
override suspend fun store(values: CurrencyIdWithQuote, fiatCurrency: FiatCurrency) {
if (values.isEmpty()) return
coroutineScope {
launch { storeInRuntime(values = values) }
launch { storeInPersistence(values = values) }
launch { storeInRuntime(values = values, fiatCurrency = fiatCurrency) }
launch { storeInPersistence(values = values, fiatCurrency = fiatCurrency) }
}
}
private suspend fun storeInRuntime(values: CurrencyIdWithQuote) {
val quotes = QuoteStatusConverter(source = StatusSource.ACTUAL).convertSet(input = values.entries)
private suspend fun storeInRuntime(values: CurrencyIdWithQuote, fiatCurrency: FiatCurrency) {
val quotes = QuoteStatusConverter(source = StatusSource.ACTUAL, fiatCurrency = fiatCurrency)
.convertSet(input = values.entries)
runtimeStore.update(default = emptySet()) { saved ->
saved.addOrReplace(items = quotes) { prev, new -> prev.rawCurrencyId == new.rawCurrencyId }
}
}
private suspend fun storeInPersistence(values: CurrencyIdWithQuote) {
persistenceDataStore.updateData { storedQuotes -> storedQuotes + values }
private suspend fun storeInPersistence(values: CurrencyIdWithQuote, fiatCurrency: FiatCurrency) {
persistenceDataStore.updateData { stored ->
val isSameCurrency = stored.fiatCurrency?.code == fiatCurrency.code
val mergedQuotes = if (isSameCurrency) stored.quotes + values else values
QuoteStatusDM(
fiatCurrency = FiatCurrencyConverter.convert(fiatCurrency),
quotes = mergedQuotes,
)
}
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.data.quotes.store
import com.squareup.moshi.JsonClass
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
/**
* Persisted form of [com.tangem.domain.models.quote.QuoteStatus] cache. Carries the fiat currency
* the quotes are expressed in, so it can be restored on cold start.
*
* @property fiatCurrency fiat currency the [quotes] are expressed in; `null` for an empty default cache
* @property quotes map of currency id to its quote
*/
@JsonClass(generateAdapter = true)
internal data class QuoteStatusDM(
val fiatCurrency: FiatCurrency?,
val quotes: Map<String, QuotesResponse.Quote>,
) {
@JsonClass(generateAdapter = true)
internal data class FiatCurrency(
val code: String,
val symbol: String,
)
companion object {
val Empty = QuoteStatusDM(fiatCurrency = null, quotes = emptyMap())
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.data.quotes.store
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.models.quote.QuoteStatus
import kotlinx.coroutines.flow.Flow
@ -38,9 +39,10 @@ internal interface QuotesStatusesStore {
/**
* Store quotes statuses
*
* @param values map of currency ids and quotes
* @param values map of currency ids and quotes
* @param fiatCurrency fiat currency the [values] are expressed in
*
* See complex methods in `QuotesStatusesStoreExt`.
*/
suspend fun store(values: Map<String, QuotesResponse.Quote>)
suspend fun store(values: Map<String, QuotesResponse.Quote>, fiatCurrency: FiatCurrency)
}

View file

@ -6,6 +6,7 @@ import com.tangem.common.test.data.quote.toDomain
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
@ -24,7 +25,8 @@ internal class QuoteStatusConverterTest {
@ProvideTestModels
fun convert(model: ConvertTestModel) {
// Act
val actual = QuoteStatusConverter(source = model.source).convert(value = model.value)
val actual = QuoteStatusConverter(source = model.source, fiatCurrency = FiatCurrency.Default)
.convert(value = model.value)
// Assert
val expected = model.expected
@ -62,6 +64,7 @@ internal class QuoteStatusConverterTest {
rawCurrencyId = CryptoCurrency.RawID("ETH"),
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatCurrency = FiatCurrency.Default,
fiatRate = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
priceChange = BigDecimal("0.00"),
@ -83,6 +86,7 @@ internal class QuoteStatusConverterTest {
rawCurrencyId = CryptoCurrency.RawID("ETH"),
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatCurrency = FiatCurrency.Default,
fiatRate = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
priceChange = BigDecimal("0.00"),
@ -104,6 +108,7 @@ internal class QuoteStatusConverterTest {
rawCurrencyId = CryptoCurrency.RawID("ETH"),
value = QuoteStatus.Data(
source = StatusSource.ACTUAL,
fiatCurrency = FiatCurrency.Default,
fiatRate = BigDecimal.ONE,
fiatRateUSD = BigDecimal.ONE,
priceChange = BigDecimal("0.01"),

View file

@ -69,7 +69,7 @@ internal class DefaultMultiQuoteStatusFetcherTest {
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields)
quotesStore.store(values = successResponse.quotes)
quotesStore.store(values = successResponse.quotes, fiatCurrency = any())
}
coVerify(inverse = true) {
@ -93,21 +93,21 @@ internal class DefaultMultiQuoteStatusFetcherTest {
quotesStore.setSourceAsCache(currenciesIds = any())
appCurrencyResponseStore.getSyncOrNull()
quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any())
quotesStore.store(values = any())
quotesStore.store(values = any(), fiatCurrency = any())
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
}
}
@Test
fun `fetch successfully if appCurrencyId from params is not null`() = runTest {
// Arrange
val appCurrencyId = "usd"
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
fun `fetch ignores params appCurrencyId and uses stored app currency`() = runTest {
// Arrange: params.appCurrencyId is set but different from stored — fetcher must still use stored
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = "eur")
val currenciesIds = setOf("BTC", "ETH")
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
coEvery {
quotesFetcher.fetch(fiatCurrencyId = appCurrencyId, currenciesIds = currenciesIds, fields = fields)
quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields)
} returns successResponse.right()
// Act
@ -119,42 +119,16 @@ internal class DefaultMultiQuoteStatusFetcherTest {
coVerifyOrder {
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
quotesFetcher.fetch(fiatCurrencyId = appCurrencyId, currenciesIds = currenciesIds, fields = fields)
quotesStore.store(values = successResponse.quotes)
appCurrencyResponseStore.getSyncOrNull()
quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields)
quotesStore.store(values = successResponse.quotes, fiatCurrency = any())
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
}
}
@Test
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)
}
coVerify(inverse = true) {
appCurrencyResponseStore.getSyncOrNull()
quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any())
quotesStore.store(values = any())
}
}
@Test
fun `fetch failure because api request failed`() = runTest {
// Arrange
@ -186,7 +160,7 @@ internal class DefaultMultiQuoteStatusFetcherTest {
}
coVerify(inverse = true) {
quotesStore.store(values = any())
quotesStore.store(values = any(), fiatCurrency = any())
}
}
@ -213,7 +187,7 @@ internal class DefaultMultiQuoteStatusFetcherTest {
coVerify(inverse = true) {
quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any())
quotesStore.store(values = any())
quotesStore.store(values = any(), fiatCurrency = any())
}
}

View file

@ -4,6 +4,7 @@ import com.google.common.truth.Truth
import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.quotes.QuotesRepository
import com.tangem.test.core.ProvideTestModels
@ -41,6 +42,7 @@ internal class DefaultQuotesRepositoryTest {
private val ethQuote = QuoteStatus(
rawCurrencyId = ethRawId,
value = QuoteStatus.Data(
fiatCurrency = FiatCurrency.Default,
source = StatusSource.ACTUAL,
fiatRate = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
@ -111,6 +113,7 @@ internal class DefaultQuotesRepositoryTest {
private val ethQuote = QuoteStatus(
rawCurrencyId = ethRawId,
value = QuoteStatus.Data(
fiatCurrency = FiatCurrency.Default,
source = StatusSource.ACTUAL,
fiatRate = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,

View file

@ -5,6 +5,7 @@ import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.test.core.getEmittedValues
@ -81,6 +82,7 @@ internal class DefaultSingleQuoteStatusProducerTest {
val updatedStatus = QuoteStatus(
rawCurrencyId = params.rawCurrencyId,
value = QuoteStatus.Data(
fiatCurrency = FiatCurrency.Default,
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
@ -129,6 +131,7 @@ internal class DefaultSingleQuoteStatusProducerTest {
val status = QuoteStatus(
rawCurrencyId = params.rawCurrencyId,
value = QuoteStatus.Data(
fiatCurrency = FiatCurrency.Default,
fiatRate = BigDecimal.ONE,
fiatRateUSD = BigDecimal.ZERO,
priceChange = BigDecimal.ZERO,

View file

@ -25,7 +25,7 @@ import kotlin.properties.Delegates
internal class QuotesStatusesStoreExtTest {
private var runtimeStore: RuntimeSharedStore<Set<QuoteStatus>> by Delegates.notNull()
private var persistenceStore: MockStateDataStore<CurrencyIdWithQuote> by Delegates.notNull()
private var persistenceStore: MockStateDataStore<QuoteStatusDM> by Delegates.notNull()
private var store: DefaultQuotesStatusesStore by Delegates.notNull()
private val btcQuoteDM = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
@ -34,11 +34,12 @@ internal class QuotesStatusesStoreExtTest {
@BeforeEach
fun resetMocks() {
runtimeStore = RuntimeSharedStore()
persistenceStore = MockStateDataStore(default = emptyMap())
persistenceStore = MockStateDataStore(default = QuoteStatusDM.Empty)
store = DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
legacyCacheFile = java.io.File("legacy-quotes-cache-noop"),
scope = TestAppCoroutineScope(),
)
}

View file

@ -6,9 +6,11 @@ import com.tangem.common.test.TestAppCoroutineScope
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.data.quotes.converter.FiatCurrencyConverter
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.FiatCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.test.core.ProvideTestModels
import com.tangem.test.core.getEmittedValues
@ -31,7 +33,7 @@ import kotlin.properties.Delegates
internal class QuotesStatusesStoreTest {
private var runtimeStore: RuntimeSharedStore<Set<QuoteStatus>> by Delegates.notNull()
private var persistenceStore: MockStateDataStore<CurrencyIdWithQuote> by Delegates.notNull()
private var persistenceStore: MockStateDataStore<QuoteStatusDM> by Delegates.notNull()
private var store: DefaultQuotesStatusesStore by Delegates.notNull()
// region Data models
@ -48,11 +50,12 @@ internal class QuotesStatusesStoreTest {
@BeforeEach
fun resetMocks() {
runtimeStore = RuntimeSharedStore()
persistenceStore = MockStateDataStore(default = emptyMap())
persistenceStore = MockStateDataStore(default = QuoteStatusDM.Empty)
store = DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
legacyCacheFile = java.io.File("legacy-quotes-cache-noop"),
scope = TestAppCoroutineScope(),
)
}
@ -65,7 +68,7 @@ internal class QuotesStatusesStoreTest {
fun `initialization if cache store is empty`() = runTest {
// Arrange
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
val persistenceStore: DataStore<QuoteStatusDM> = mockk()
every { persistenceStore.data } returns emptyFlow()
@ -73,6 +76,7 @@ internal class QuotesStatusesStoreTest {
DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
legacyCacheFile = java.io.File("legacy-quotes-cache-noop"),
scope = TestAppCoroutineScope(),
)
@ -87,12 +91,13 @@ internal class QuotesStatusesStoreTest {
fun `initialization if cache store contains empty map`() = runTest {
// Arrange
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
val persistenceStore = MockStateDataStore<QuoteStatusDM>(default = QuoteStatusDM.Empty)
// Act
DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
legacyCacheFile = java.io.File("legacy-quotes-cache-noop"),
scope = TestAppCoroutineScope(),
)
@ -107,19 +112,20 @@ internal class QuotesStatusesStoreTest {
fun `initialization if cache store is not empty`() = runTest {
// Arrange
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
val persistenceStore = MockStateDataStore<QuoteStatusDM>(default = QuoteStatusDM.Empty)
persistenceStore.updateData {
it.toMutableMap().apply {
this += btcQuoteDM
this += ethQuoteDM
}
QuoteStatusDM(
fiatCurrency = FiatCurrencyConverter.convert(FiatCurrency.Default),
quotes = mapOf(btcQuoteDM, ethQuoteDM),
)
}
// Act
DefaultQuotesStatusesStore(
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
legacyCacheFile = java.io.File("legacy-quotes-cache-noop"),
scope = TestAppCoroutineScope(),
)
@ -471,7 +477,7 @@ internal class QuotesStatusesStoreTest {
}
// Act
store.store(values = model.values)
store.store(values = model.values, fiatCurrency = FiatCurrency.Default)
val runtimeActual = runtimeStore.getSyncOrNull()
val persistenceActual = getEmittedValues(persistenceStore.data)
@ -491,14 +497,14 @@ internal class QuotesStatusesStoreTest {
initialPersistence = null,
values = emptyMap(),
runtimeExpected = null,
persistenceExpected = emptyMap(),
persistenceExpected = QuoteStatusDM.Empty,
),
StoreTestModel(
initialRuntime = null,
initialPersistence = null,
values = mapOf(btcQuoteDM),
runtimeExpected = setOf(btcQuote),
persistenceExpected = mapOf(btcQuoteDM),
persistenceExpected = persistedDefault(btcQuoteDM),
),
// endregion
@ -508,48 +514,48 @@ internal class QuotesStatusesStoreTest {
initialPersistence = null,
values = emptyMap(),
runtimeExpected = setOf(btcQuote),
persistenceExpected = emptyMap(),
persistenceExpected = QuoteStatusDM.Empty,
),
StoreTestModel(
initialRuntime = setOf(ethQuote),
initialPersistence = null,
values = mapOf(btcQuoteDM),
runtimeExpected = setOf(ethQuote, btcQuote),
persistenceExpected = mapOf(btcQuoteDM),
persistenceExpected = persistedDefault(btcQuoteDM),
),
// endregion
// region runtime store is null
StoreTestModel(
initialRuntime = null,
initialPersistence = mapOf(btcQuoteDM),
initialPersistence = persistedDefault(btcQuoteDM),
values = emptyMap(),
runtimeExpected = null,
persistenceExpected = mapOf(btcQuoteDM),
persistenceExpected = persistedDefault(btcQuoteDM),
),
StoreTestModel(
initialRuntime = null,
initialPersistence = mapOf(ethQuoteDM),
initialPersistence = persistedDefault(ethQuoteDM),
values = mapOf(btcQuoteDM),
runtimeExpected = setOf(btcQuote),
persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM),
persistenceExpected = persistedDefault(ethQuoteDM, btcQuoteDM),
),
// endregion
// region stores contain data
StoreTestModel(
initialRuntime = setOf(btcQuote),
initialPersistence = mapOf(btcQuoteDM),
initialPersistence = persistedDefault(btcQuoteDM),
values = emptyMap(),
runtimeExpected = setOf(btcQuote),
persistenceExpected = mapOf(btcQuoteDM),
persistenceExpected = persistedDefault(btcQuoteDM),
),
StoreTestModel(
initialRuntime = setOf(btcQuote),
initialPersistence = mapOf(btcQuoteDM),
initialPersistence = persistedDefault(btcQuoteDM),
values = mapOf(ethQuoteDM),
runtimeExpected = setOf(ethQuote, btcQuote),
persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM),
persistenceExpected = persistedDefault(ethQuoteDM, btcQuoteDM),
),
// endregion
)
@ -557,9 +563,18 @@ internal class QuotesStatusesStoreTest {
data class StoreTestModel(
val initialRuntime: Set<QuoteStatus>?,
val initialPersistence: CurrencyIdWithQuote?,
val initialPersistence: QuoteStatusDM?,
val values: CurrencyIdWithQuote,
val persistenceExpected: CurrencyIdWithQuote?,
val persistenceExpected: QuoteStatusDM?,
val runtimeExpected: Set<QuoteStatus>?,
)
companion object {
private fun persistedDefault(
vararg quotes: Pair<String, com.tangem.datasource.api.tangemTech.models.QuotesResponse.Quote>,
): QuoteStatusDM = QuoteStatusDM(
fiatCurrency = FiatCurrencyConverter.convert(FiatCurrency.Default),
quotes = mapOf(*quotes),
)
}
}