Updated on 2026-08-14
This commit is contained in:
commit
86d72b171f
657 changed files with 19875 additions and 4004 deletions
|
|
@ -39,6 +39,7 @@ internal object BlockAidMapper {
|
|||
} else {
|
||||
mapSimulationSuccessResult(from.simulation.accountSummary)
|
||||
},
|
||||
description = from.validation.description,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ class BlockAidMapperTest {
|
|||
spenders = mapOf("spender" to spenderDetails),
|
||||
)
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(
|
||||
|
|
@ -79,7 +79,7 @@ class BlockAidMapperTest {
|
|||
outTransfer = listOf(Transfer(value = "1.5", rawValue = "0x2")),
|
||||
)
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(
|
||||
|
|
@ -105,7 +105,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response error validation rhen returns failed to validate`() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Error", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Error", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null),
|
||||
|
|
@ -120,7 +120,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response not benign then returns validation unsafe`() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Phishing"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Phishing", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null),
|
||||
|
|
@ -134,7 +134,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response simulation not success then returns simulation failed ro simulate`() {
|
||||
val response = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Error",
|
||||
accountSummary = AccountSummaryResponse(emptyList(), emptyList(), null),
|
||||
|
|
@ -148,7 +148,7 @@ class BlockAidMapperTest {
|
|||
@Test
|
||||
fun `when response simulation is empty then returns failed to simulate`() {
|
||||
val txResponse = TransactionScanResponse(
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign"),
|
||||
validation = ValidationResponse(status = "Success", resultType = "Benign", description = ""),
|
||||
simulation = SimulationResponse(
|
||||
status = "Success",
|
||||
accountSummary = AccountSummaryResponse(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ internal class DefaultCardCryptoCurrencyFactory(
|
|||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : CardCryptoCurrencyFactory {
|
||||
|
||||
private val cryptoCurrencyFactory by lazy { CryptoCurrencyFactory(excludedBlockchains) }
|
||||
|
|
@ -142,9 +143,7 @@ internal class DefaultCardCryptoCurrencyFactory(
|
|||
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
|
||||
?: return emptyMap()
|
||||
|
||||
val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains)
|
||||
|
||||
return responseCurrenciesFactory.createCurrencies(
|
||||
return responseCryptoCurrenciesFactory.createCurrencies(
|
||||
tokens = response.tokens.filter { token ->
|
||||
networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath }
|
||||
},
|
||||
|
|
@ -160,11 +159,9 @@ internal class DefaultCardCryptoCurrencyFactory(
|
|||
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
|
||||
?: return emptyMap()
|
||||
|
||||
val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains)
|
||||
|
||||
val networkIds = rawIds.map { it.toBlockchain().toNetworkId() }
|
||||
|
||||
return responseCurrenciesFactory.createCurrencies(
|
||||
return responseCryptoCurrenciesFactory.createCurrencies(
|
||||
tokens = response.tokens.filter { token -> token.networkId in networkIds },
|
||||
scanResponse = userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.tangem.data.common.currency
|
|||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.Token
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.data.common.network.NetworkFactory
|
||||
|
|
@ -11,11 +10,12 @@ import com.tangem.domain.common.util.cardTypesResolver
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import com.tangem.blockchain.common.Token as SdkToken
|
||||
|
||||
class ResponseCryptoCurrenciesFactory(excludedBlockchains: ExcludedBlockchains) {
|
||||
|
||||
private val networkFactory by lazy(LazyThreadSafetyMode.NONE) { NetworkFactory(excludedBlockchains) }
|
||||
class ResponseCryptoCurrenciesFactory @Inject constructor(
|
||||
private val networkFactory: NetworkFactory,
|
||||
) {
|
||||
|
||||
fun createCurrency(currencyId: String, response: UserTokensResponse, scanResponse: ScanResponse): CryptoCurrency {
|
||||
return response.tokens
|
||||
|
|
@ -25,11 +25,7 @@ class ResponseCryptoCurrenciesFactory(excludedBlockchains: ExcludedBlockchains)
|
|||
}
|
||||
|
||||
fun createCurrencies(response: UserTokensResponse, scanResponse: ScanResponse): List<CryptoCurrency> {
|
||||
return response.tokens
|
||||
.asSequence()
|
||||
.mapNotNull { createCurrency(it, scanResponse) }
|
||||
.distinctBy { it.id }
|
||||
.toList()
|
||||
return createCurrencies(tokens = response.tokens, scanResponse = scanResponse)
|
||||
}
|
||||
|
||||
fun createCurrencies(tokens: List<UserTokensResponse.Token>, scanResponse: ScanResponse): List<CryptoCurrency> {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,15 @@ import com.tangem.data.common.api.safeApiCall
|
|||
import com.tangem.data.common.tokens.UserTokensBackwardCompatibility
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.storeObject
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
class UserTokensSaver constructor(
|
||||
class UserTokensSaver(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher,
|
||||
) {
|
||||
|
|
@ -31,10 +29,8 @@ class UserTokensSaver constructor(
|
|||
} else {
|
||||
compatibleUserTokensResponse
|
||||
}
|
||||
appPreferencesStore.storeObject(
|
||||
key = PreferencesKeys.getUserTokensKey(userWalletId = userWalletId.stringValue),
|
||||
value = enrichedUserTokensResponse,
|
||||
)
|
||||
|
||||
userTokensResponseStore.store(userWalletId = userWalletId, response = enrichedUserTokensResponse)
|
||||
}
|
||||
|
||||
suspend fun storeAndPush(userWalletId: UserWalletId, response: UserTokensResponse) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
package com.tangem.data.common.di
|
||||
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
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.currency.*
|
||||
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
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.demo.DemoConfig
|
||||
|
|
@ -30,12 +28,14 @@ internal object DataCommonModule {
|
|||
excludedBlockchains: ExcludedBlockchains,
|
||||
userWalletsStore: UserWalletsStore,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
): CardCryptoCurrencyFactory {
|
||||
return DefaultCardCryptoCurrencyFactory(
|
||||
demoConfig = DemoConfig(),
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
userWalletsStore = userWalletsStore,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -59,15 +59,21 @@ internal object DataCommonModule {
|
|||
@Singleton
|
||||
fun provideUserTokensSaver(
|
||||
tangemTechApi: TangemTechApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
enricher: UserTokensResponseAddressesEnricher,
|
||||
): UserTokensSaver {
|
||||
return UserTokensSaver(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
dispatchers = dispatchers,
|
||||
userTokensResponseAddressesEnricher = enricher,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideQuotesFetcher(tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider): QuotesFetcher {
|
||||
return DefaultQuotesFetcher(tangemTechApi = tangemTechApi, dispatchers = dispatchers)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -8,6 +8,7 @@ import com.tangem.common.card.WalletData
|
|||
import com.tangem.common.test.domain.card.MockScanResponseFactory
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.data.common.network.NetworkFactory
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
|
|
@ -34,12 +35,16 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
|
|||
|
||||
private val userWalletsStore: UserWalletsStore = mockk()
|
||||
private val userTokensResponseStore: UserTokensResponseStore = mockk()
|
||||
private val excludedBlockchains = ExcludedBlockchains()
|
||||
|
||||
private val factory = DefaultCardCryptoCurrencyFactory(
|
||||
demoConfig = DemoConfig(),
|
||||
excludedBlockchains = ExcludedBlockchains(),
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
userWalletsStore = userWalletsStore,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
responseCryptoCurrenciesFactory = ResponseCryptoCurrenciesFactory(
|
||||
networkFactory = NetworkFactory(excludedBlockchains = excludedBlockchains),
|
||||
),
|
||||
)
|
||||
|
||||
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
||||
|
|
|
|||
|
|
@ -1,51 +1,35 @@
|
|||
package com.tangem.data.common.currency
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.squareup.moshi.Moshi
|
||||
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.UserTokensResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class UserTokensSaverTest {
|
||||
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val dataStore: DataStore<Preferences> = mockk(relaxed = true)
|
||||
private val appPreferenceStore = AppPreferencesStore(
|
||||
moshi = Moshi.Builder().build(),
|
||||
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxed = true)
|
||||
private val enricher: UserTokensResponseAddressesEnricher = mockk()
|
||||
|
||||
private val userTokensSaver: UserTokensSaver = UserTokensSaver(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
userTokensResponseAddressesEnricher = enricher,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
preferencesDataStore = dataStore,
|
||||
)
|
||||
private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider()
|
||||
private val userTokensResponseAddressesEnricher: UserTokensResponseAddressesEnricher = mockk()
|
||||
|
||||
private lateinit var userTokensSaver: UserTokensSaver
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
coEvery { dataStore.data } returns flowOf(mockk(relaxed = true))
|
||||
userTokensSaver = UserTokensSaver(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appPreferencesStore = appPreferenceStore,
|
||||
dispatchers = dispatchers,
|
||||
userTokensResponseAddressesEnricher = userTokensResponseAddressesEnricher,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
clearAllMocks()
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(tangemTechApi, userTokensResponseStore, enricher)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -58,57 +42,30 @@ class UserTokensSaverTest {
|
|||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
|
||||
val enrichedResponse = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
coEvery { userTokensResponseAddressesEnricher(userWalletId, response) } returns enrichedResponse
|
||||
coEvery { dataStore.updateData(any()) } returns mockk(relaxed = true)
|
||||
|
||||
coEvery { enricher(userWalletId, response) } returns enrichedResponse
|
||||
|
||||
// WHEN
|
||||
userTokensSaver.store(userWalletId, response)
|
||||
|
||||
// THEN
|
||||
coVerify {
|
||||
dataStore.updateData(any())
|
||||
coVerifyOrder {
|
||||
enricher(userWalletId, response)
|
||||
userTokensResponseStore.store(userWalletId, enrichedResponse)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.saveUserTokens(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest {
|
||||
// GIVEN
|
||||
val userWalletId = UserWalletId("1234567890abcdef")
|
||||
val response = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
val enrichedResponse = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
coEvery { userTokensResponseAddressesEnricher(userWalletId, response) } returns enrichedResponse
|
||||
coEvery { dataStore.updateData(any()) } returns mockk(relaxed = true)
|
||||
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns ApiResponse.Success(Unit)
|
||||
|
||||
// WHEN
|
||||
userTokensSaver.storeAndPush(userWalletId, response)
|
||||
|
||||
// THEN
|
||||
coVerify {
|
||||
dataStore.updateData(any())
|
||||
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet id and response WHEN push AND api call fails THEN should log error and call onFailSend`() =
|
||||
runTest {
|
||||
|
|
@ -123,14 +80,13 @@ class UserTokensSaverTest {
|
|||
val enrichedResponse = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
val error = ApiResponseError.UnknownException(Exception("API Error"))
|
||||
var onFailSendCalled = false
|
||||
coEvery { userTokensResponseAddressesEnricher(userWalletId, response) } returns enrichedResponse
|
||||
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns
|
||||
ApiResponse.Error(error) as ApiResponse<Unit>
|
||||
coEvery { enricher(userWalletId, response) } returns enrichedResponse
|
||||
coEvery { tangemTechApi.saveUserTokens(any(), any()) } returns ApiResponse.Error(error) as ApiResponse<Unit>
|
||||
|
||||
// WHEN
|
||||
userTokensSaver.push(
|
||||
|
|
@ -140,12 +96,42 @@ class UserTokensSaverTest {
|
|||
)
|
||||
|
||||
// THEN
|
||||
coVerify {
|
||||
coVerifyOrder {
|
||||
enricher(userWalletId, response)
|
||||
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
dataStore.updateData(any())
|
||||
}
|
||||
|
||||
assert(onFailSendCalled) { "onFailSend callback should be called when API call fails" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN user wallet id and response WHEN storeAndPush THEN should store and push enriched response`() = runTest {
|
||||
// GIVEN
|
||||
val userWalletId = UserWalletId("1234567890abcdef")
|
||||
val response = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
val enrichedResponse = UserTokensResponse(
|
||||
version = 0,
|
||||
group = UserTokensResponse.GroupType.NETWORK,
|
||||
sort = UserTokensResponse.SortType.BALANCE,
|
||||
tokens = emptyList(),
|
||||
)
|
||||
coEvery { enricher(userWalletId, response) } returns enrichedResponse
|
||||
coEvery {
|
||||
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
|
||||
} returns ApiResponse.Success(Unit)
|
||||
|
||||
// WHEN
|
||||
userTokensSaver.storeAndPush(userWalletId, response)
|
||||
|
||||
// THEN
|
||||
coVerifyOrder {
|
||||
enricher(userWalletId, response)
|
||||
tangemTechApi.saveUserTokens(userWalletId.stringValue, enrichedResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
1
data/express/.gitignore
vendored
Normal file
1
data/express/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
36
data/express/build.gradle.kts
Normal file
36
data/express/build.gradle.kts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.express"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.wallets.models)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.timber)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.tangem.data.express
|
||||
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.express.converter.ExpressProviderConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressUtils
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import timber.log.Timber
|
||||
|
||||
internal class DefaultExpressRepository(
|
||||
private val tangemExpressApi: TangemExpressApi,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : ExpressRepository {
|
||||
|
||||
override suspend fun getProviders(userWallet: UserWallet): List<ExpressProvider> {
|
||||
return safeApiCall(
|
||||
call = {
|
||||
tangemExpressApi.getProviders(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
),
|
||||
).getOrThrow().map(ExpressProviderConverter()::convert)
|
||||
},
|
||||
onError = {
|
||||
Timber.w(it, "Unable to fetch express providers")
|
||||
throw it
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.tangem.data.express.converter
|
||||
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.datasource.api.express.models.response.ExpressError as ExpressErrorDTO
|
||||
|
||||
class ExpressErrorConverter(
|
||||
private val jsonAdapter: JsonAdapter<ExpressErrorResponse>,
|
||||
) : Converter<String, ExpressError> {
|
||||
|
||||
@Suppress("MagicNumber", "CyclomaticComplexMethod")
|
||||
override fun convert(value: String): ExpressError {
|
||||
try {
|
||||
val error: ExpressErrorDTO = jsonAdapter.fromJson(value)?.error ?: return ExpressError.UnknownError
|
||||
|
||||
return when (error.code) {
|
||||
2010 -> ExpressError.BadRequest(code = error.code)
|
||||
2020 -> ExpressError.Forbidden(code = error.code)
|
||||
|
||||
2200 -> ExpressError.InternalError(code = error.code)
|
||||
2210 -> ExpressError.ProviderNotFoundError(code = error.code)
|
||||
2220 -> ExpressError.ProviderNotActiveError(code = error.code)
|
||||
2230 -> ExpressError.ProviderNotAvailableError(code = error.code)
|
||||
2231 -> ExpressError.ProviderInternalError(code = error.code)
|
||||
2240 -> ExpressError.ExchangeNotPossibleError(code = error.code)
|
||||
2250 -> error.toTooSmallAmountError()
|
||||
2251 -> error.toTooBigAmountError()
|
||||
2260 -> error.toNotEnoughAllowanceError()
|
||||
2270 -> ExpressError.NotEnoughBalanceError(code = error.code)
|
||||
2280 -> ExpressError.InvalidAddressError(code = error.code)
|
||||
2290 -> error.toInvalidFromDecimalsError()
|
||||
|
||||
2320 -> error.toProviderDifferentAmountError()
|
||||
else -> ExpressError.DataError(error.code, error.description)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return ExpressError.UnknownError
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toTooSmallAmountError(): ExpressError {
|
||||
val minAmount = value?.minAmount ?: return ExpressError.DataError(code, description)
|
||||
val decimals = value?.decimals ?: return ExpressError.DataError(code, description)
|
||||
val requiredAmount = minAmount.toBigDecimalOrNull()?.movePointLeft(decimals)
|
||||
?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.AmountError.TooSmallError(code = code, amount = requiredAmount)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toTooBigAmountError(): ExpressError {
|
||||
val maxAmount = value?.maxAmount ?: return ExpressError.DataError(code, description)
|
||||
val decimals = value?.decimals ?: return ExpressError.DataError(code, description)
|
||||
val requiredAmount = maxAmount.toBigDecimalOrNull()?.movePointLeft(decimals)
|
||||
?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.AmountError.TooBigError(code = code, amount = requiredAmount)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toNotEnoughAllowanceError(): ExpressError {
|
||||
val currentAllowance = value?.currentAllowance ?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.AmountError.NotEnoughAllowanceError(code = code, amount = currentAllowance)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toInvalidFromDecimalsError(): ExpressError {
|
||||
val receivedFromDecimals = value?.receivedFromDecimals ?: return ExpressError.DataError(code, description)
|
||||
val expressFromDecimals = value?.expressFromDecimals ?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.InvalidFromDecimalsError(
|
||||
code = code,
|
||||
receivedFromDecimals = receivedFromDecimals,
|
||||
expressFromDecimals = expressFromDecimals,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ExpressErrorDTO.toProviderDifferentAmountError(): ExpressError {
|
||||
val decimals = value?.decimals ?: return ExpressError.DataError(code, description)
|
||||
val fromAmount = value?.fromAmount?.toBigDecimalOrNull() ?: return ExpressError.DataError(code, description)
|
||||
val fromAmountProvider = value?.fromAmountProvider?.toBigDecimalOrNull()
|
||||
?: return ExpressError.DataError(code, description)
|
||||
|
||||
return ExpressError.ProviderDifferentAmountError(
|
||||
code = code,
|
||||
decimals = decimals,
|
||||
fromAmount = fromAmount.movePointLeft(decimals),
|
||||
fromProviderAmount = fromAmountProvider.movePointLeft(decimals),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.tangem.data.express.converter
|
||||
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeProvider
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeProviderType
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressProviderType
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class ExpressProviderConverter : Converter<ExchangeProvider, ExpressProvider> {
|
||||
|
||||
override fun convert(value: ExchangeProvider): ExpressProvider {
|
||||
return ExpressProvider(
|
||||
providerId = value.id,
|
||||
rateTypes = emptyList(),
|
||||
name = value.name,
|
||||
type = convertExchangeType(value.type),
|
||||
imageLarge = value.imageLargeUrl,
|
||||
termsOfUse = value.termsOfUse,
|
||||
privacyPolicy = value.privacyPolicy,
|
||||
isRecommended = value.isRecommended,
|
||||
slippage = value.slippage,
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertExchangeType(type: ExchangeProviderType): ExpressProviderType {
|
||||
return when (type) {
|
||||
ExchangeProviderType.DEX -> ExpressProviderType.DEX
|
||||
ExchangeProviderType.CEX -> ExpressProviderType.CEX
|
||||
ExchangeProviderType.DEX_BRIDGE -> ExpressProviderType.DEX_BRIDGE
|
||||
ExchangeProviderType.ONRAMP -> ExpressProviderType.ONRAMP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.tangem.data.express.di
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.express.DefaultExpressRepository
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.data.express.error.DefaultExpressErrorResolver
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.ExpressErrorResolver
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object ExpressDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExpressErrorResolver(@NetworkMoshi moshi: Moshi): ExpressErrorResolver {
|
||||
val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java)
|
||||
return DefaultExpressErrorResolver(
|
||||
ExpressErrorConverter(jsonAdapter),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExpressRepository(
|
||||
tangemExpressApi: TangemExpressApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
): ExpressRepository {
|
||||
return DefaultExpressRepository(
|
||||
tangemExpressApi = tangemExpressApi,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.express.error
|
||||
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.express.ExpressErrorResolver
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
|
||||
internal class DefaultExpressErrorResolver(
|
||||
private val expressErrorConverter: ExpressErrorConverter,
|
||||
) : ExpressErrorResolver {
|
||||
|
||||
override fun resolve(throwable: Throwable): ExpressError {
|
||||
return when (throwable) {
|
||||
is ApiResponseError.HttpException -> {
|
||||
expressErrorConverter.convert(throwable.errorBody.orEmpty())
|
||||
}
|
||||
else -> ExpressError.UnknownError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,10 +12,7 @@ import com.tangem.data.common.network.NetworkFactory
|
|||
import com.tangem.data.managetokens.utils.TokenAddressesConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.common.extensions.canHandleBlockchain
|
||||
import com.tangem.domain.common.extensions.supportedBlockchains
|
||||
|
|
@ -35,7 +32,7 @@ import kotlinx.coroutines.withContext
|
|||
internal class DefaultCustomTokensRepository(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
|
|
@ -57,6 +54,8 @@ internal class DefaultCustomTokensRepository(
|
|||
-> true
|
||||
Blockchain.Cardano,
|
||||
Blockchain.Sui,
|
||||
Blockchain.Stellar,
|
||||
Blockchain.XRP,
|
||||
-> blockchain.validateContractAddress(contractAddress)
|
||||
else -> blockchain.validateAddress(contractAddress)
|
||||
}
|
||||
|
|
@ -69,9 +68,8 @@ internal class DefaultCustomTokensRepository(
|
|||
contractAddress: String?,
|
||||
): Boolean {
|
||||
return withContext(dispatchers.io) {
|
||||
val storedCurrencies = appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
|
||||
key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue),
|
||||
)
|
||||
val storedCurrencies = userTokensResponseStore.getSyncOrNull(userWalletId)
|
||||
|
||||
requireNotNull(storedCurrencies) {
|
||||
"User tokens not found for user wallet [$userWalletId] while checking if currency is not added"
|
||||
}
|
||||
|
|
@ -228,9 +226,8 @@ internal class DefaultCustomTokensRepository(
|
|||
contractAddress = currency.contractAddress,
|
||||
)
|
||||
}
|
||||
val storedCurrencies = appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
|
||||
key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue),
|
||||
)
|
||||
val storedCurrencies = userTokensResponseStore.getSyncOrNull(userWalletId)
|
||||
|
||||
requireNotNull(storedCurrencies) {
|
||||
"User tokens not found for user wallet [$userWalletId] while removing currency"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ import com.tangem.datasource.api.common.response.getOrThrow
|
|||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.common.TapWorkarounds.isTestCard
|
||||
import com.tangem.domain.common.extensions.canHandleBlockchain
|
||||
|
|
@ -46,7 +44,7 @@ internal class DefaultManageTokensRepository(
|
|||
private val tangemTechApi: TangemTechApi,
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
private val manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val testnetTokensStorage: TestnetTokensStorage,
|
||||
private val excludedBlockchains: ExcludedBlockchains,
|
||||
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
|
||||
|
|
@ -251,9 +249,7 @@ internal class DefaultManageTokensRepository(
|
|||
}
|
||||
|
||||
private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? {
|
||||
return appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
|
||||
key = PreferencesKeys.getUserTokensKey(key.stringValue),
|
||||
)
|
||||
return userTokensResponseStore.getSyncOrNull(userWalletId = key)
|
||||
}
|
||||
|
||||
override suspend fun checkCurrencyUnsupportedState(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import com.tangem.data.managetokens.DefaultManageTokensRepository
|
|||
import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.managetokens.repository.CustomTokensRepository
|
||||
import com.tangem.domain.managetokens.repository.ManageTokensRepository
|
||||
|
|
@ -31,7 +31,7 @@ internal object ManageTokensDataModule {
|
|||
tangemTechApi: TangemTechApi,
|
||||
userWalletsStore: UserWalletsStore,
|
||||
manageTokensUpdateFetcher: ManageTokensUpdateFetcher,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
testnetTokensStorage: TestnetTokensStorage,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
|
|
@ -42,7 +42,7 @@ internal object ManageTokensDataModule {
|
|||
tangemTechApi = tangemTechApi,
|
||||
userWalletsStore = userWalletsStore,
|
||||
manageTokensUpdateFetcher = manageTokensUpdateFetcher,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
testnetTokensStorage = testnetTokensStorage,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
|
||||
|
|
@ -56,7 +56,7 @@ internal object ManageTokensDataModule {
|
|||
fun provideCustomTokensRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
userWalletsStore: UserWalletsStore,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
|
|
@ -66,7 +66,7 @@ internal object ManageTokensDataModule {
|
|||
return DefaultCustomTokensRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userWalletsStore = userWalletsStore,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
dispatchers = dispatchers,
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ internal class ManagedCryptoCurrencyFactory(
|
|||
derivationStyleProvider = scanResponse?.derivationStyleProvider,
|
||||
canHandleTokens = scanResponse?.let {
|
||||
it.card.canHandleToken(blockchain, it.cardTypesResolver, excludedBlockchains)
|
||||
} ?: true,
|
||||
} ?: false, // use card specific check if available
|
||||
) ?: return null
|
||||
|
||||
return when {
|
||||
|
|
@ -187,7 +187,8 @@ internal class ManagedCryptoCurrencyFactory(
|
|||
decimals = blockchain.decimals(),
|
||||
isL2Network = l2BlockchainsList.contains(blockchain),
|
||||
)
|
||||
network.canHandleTokens -> {
|
||||
// use general check from blockchain, check availability for card in place of use
|
||||
blockchain.canHandleTokens() -> {
|
||||
val formattedContractAddress = blockchain.reformatContractAddress(contractAddress)
|
||||
if (formattedContractAddress == null) {
|
||||
Timber.w("Couldn't reformat $contractAddress")
|
||||
|
|
|
|||
|
|
@ -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<Int, List<TokenMarket>, 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Int, List<TokenMarket>, 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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ internal class DefaultNetworksStatusesStore(
|
|||
ifNotFound: (Network.ID) -> SimpleNetworkStatus?,
|
||||
) {
|
||||
if (networks.isEmpty()) {
|
||||
Timber.d("Nothing to update: networks is empty")
|
||||
Timber.d("Nothing to update: networks are empty")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
|
|||
|
||||
@Test
|
||||
fun `flow is mapped for user wallet id from params`() = runTest {
|
||||
// Assert
|
||||
// Arrange
|
||||
val statuses = setOf(
|
||||
MockNetworkStatusFactory.createVerified(ethNetwork),
|
||||
MockNetworkStatusFactory.createVerified(cardanoNetwork),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.datasource.exchangeservice.hotcrypto.HotCryptoResponseStore
|
|||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObject
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.common.extensions.canHandleBlockchain
|
||||
import com.tangem.domain.common.extensions.canHandleToken
|
||||
|
|
@ -58,6 +59,7 @@ internal class DefaultHotCryptoRepository(
|
|||
private val userWalletsStore: UserWalletsStore,
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val analyticsEventHandler: AnalyticsEventHandler,
|
||||
) : HotCryptoRepository {
|
||||
|
|
@ -103,9 +105,7 @@ internal class DefaultHotCryptoRepository(
|
|||
private fun getWalletsWithTokensFlow(): Flow<Map<UserWallet, List<UserTokensResponse.Token>>> {
|
||||
return userWalletsStore.userWallets.flatMapLatest { userWallets ->
|
||||
val flows = userWallets.map { userWallet ->
|
||||
appPreferencesStore.getObject<UserTokensResponse>(
|
||||
key = PreferencesKeys.getUserTokensKey(userWallet.walletId.stringValue),
|
||||
)
|
||||
userTokensResponseStore.get(userWalletId = userWallet.walletId)
|
||||
.map { userWallet to it?.tokens.orEmpty() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import com.tangem.datasource.api.tangemTech.models.HotCryptoResponse
|
|||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.onramp.model.HotCryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.converter.Converter
|
||||
import java.math.BigDecimal
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ internal class HotCryptoCurrencyConverter(
|
|||
|
||||
return HotCryptoCurrency(
|
||||
cryptoCurrency = currency.setupIconUrl(id = rawId.value),
|
||||
quote = createQuote(
|
||||
quoteStatus = createQuote(
|
||||
fiatRate = value.currentPrice,
|
||||
priceChange = value.priceChangePercentage,
|
||||
rawCurrencyId = rawId,
|
||||
|
|
@ -89,16 +89,18 @@ internal class HotCryptoCurrencyConverter(
|
|||
fiatRate: BigDecimal?,
|
||||
priceChange: BigDecimal?,
|
||||
rawCurrencyId: CryptoCurrency.RawID,
|
||||
): Quote {
|
||||
): QuoteStatus {
|
||||
return if (fiatRate != null && priceChange != null) {
|
||||
Quote.Value(
|
||||
QuoteStatus(
|
||||
rawCurrencyId = rawCurrencyId,
|
||||
fiatRate = fiatRate,
|
||||
priceChange = priceChange.movePointLeft(2),
|
||||
source = StatusSource.ACTUAL, // It doesn't matter
|
||||
value = QuoteStatus.Data(
|
||||
fiatRate = fiatRate,
|
||||
priceChange = priceChange.movePointLeft(2),
|
||||
source = StatusSource.ACTUAL, // It doesn't matter
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Quote.Empty(rawCurrencyId)
|
||||
QuoteStatus(rawCurrencyId = rawCurrencyId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
|||
import com.tangem.domain.onramp.model.error.OnrampError
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
@Deprecated("Use ExpressErrorConverter")
|
||||
internal class OnrampErrorConverter(
|
||||
private val jsonAdapter: JsonAdapter<ExpressErrorResponse>,
|
||||
) : Converter<String, OnrampError> {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.tangem.data.onramp.DefaultOnrampErrorResolver
|
|||
import com.tangem.data.onramp.DefaultOnrampRepository
|
||||
import com.tangem.data.onramp.DefaultOnrampTransactionRepository
|
||||
import com.tangem.data.onramp.converters.error.OnrampErrorConverter
|
||||
import com.tangem.domain.onramp.repositories.LegacyTopUpRepository
|
||||
import com.tangem.data.onramp.legacy.MercuryoTopUpRepository
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
||||
|
|
@ -24,11 +23,9 @@ import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
|
|||
import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsStore
|
||||
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.onramp.repositories.HotCryptoRepository
|
||||
import com.tangem.domain.onramp.repositories.OnrampErrorResolver
|
||||
import com.tangem.domain.onramp.repositories.OnrampRepository
|
||||
import com.tangem.domain.onramp.repositories.OnrampTransactionRepository
|
||||
import com.tangem.domain.onramp.repositories.*
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
|
|
@ -104,6 +101,7 @@ internal object OnrampDataModule {
|
|||
appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
analyticsEventHandler: AnalyticsEventHandler,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
): HotCryptoRepository {
|
||||
return DefaultHotCryptoRepository(
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
|
|
@ -113,6 +111,7 @@ internal object OnrampDataModule {
|
|||
appPreferencesStore = appPreferencesStore,
|
||||
dispatchers = dispatchers,
|
||||
analyticsEventHandler = analyticsEventHandler,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,29 +9,50 @@ android {
|
|||
namespace = "com.tangem.data.quotes"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// region Project - Core
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
api(projects.core.utils)
|
||||
// endregion
|
||||
|
||||
// region Project - Data
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.tokens)
|
||||
// endregion
|
||||
|
||||
implementation(projects.domain.appCurrency.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.quotes)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// region Project - Domain
|
||||
api(projects.domain.models)
|
||||
api(projects.domain.quotes)
|
||||
// endregion
|
||||
|
||||
// region Project - Libs
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
// endregion
|
||||
|
||||
// region DI
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
// endregion
|
||||
|
||||
// region Tangem SDKs
|
||||
implementation(tangemDeps.blockchain)
|
||||
// endregion
|
||||
|
||||
// region Other libraries
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.timber)
|
||||
// endregion
|
||||
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit)
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.tangem.data.quotes
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.QuotesRepositoryV2
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesRepositoryV2]
|
||||
*
|
||||
* @property quotesStore quotes store
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultQuotesRepositoryV2 @Inject constructor(
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
) : QuotesRepositoryV2 {
|
||||
|
||||
override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set<CryptoCurrency.RawID>): Set<Quote>? {
|
||||
return quotesStore.getAllSyncOrNull()?.mapTo(hashSetOf()) {
|
||||
it.takeIf { it.rawCurrencyId in currenciesIds } ?: Quote.Empty(it.rawCurrencyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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.quote.QuoteStatus
|
||||
import com.tangem.utils.converter.Converter
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
||||
/**
|
||||
* Converter from [QuotesResponse.Quote] to [QuoteStatus]
|
||||
*
|
||||
* @property source status source
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class QuoteStatusConverter(
|
||||
private val source: StatusSource,
|
||||
) : Converter<Map.Entry<String, QuotesResponse.Quote>, QuoteStatus> {
|
||||
|
||||
override fun convert(value: Map.Entry<String, QuotesResponse.Quote>): QuoteStatus {
|
||||
val (currencyId, quote) = value
|
||||
|
||||
return QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID(currencyId),
|
||||
value = QuoteStatus.Data(
|
||||
source = source,
|
||||
fiatRate = quote.price.orZero(),
|
||||
priceChange = quote.priceChange24h.orZero().movePointLeft(2),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteUpdater
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.domain.quotes.single.SingleQuoteFetcher
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteFetcherModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindMultiQuoteFetcher(impl: DefaultMultiQuoteFetcher): MultiQuoteFetcher
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindMultiQuoteUpdater(impl: DefaultMultiQuoteUpdater): MultiQuoteUpdater
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteFetcher(impl: DefaultSingleQuoteFetcher): SingleQuoteFetcher
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteProducerFactoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteProducerFactory(impl: DefaultSingleQuoteProducer.Factory): SingleQuoteProducer.Factory
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteStatusFetcher
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteStatusFetcherModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindMultiQuoteStatusFetcher(impl: DefaultMultiQuoteStatusFetcher): MultiQuoteStatusFetcher
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteStatusFetcher(impl: DefaultSingleQuoteStatusFetcher): SingleQuoteStatusFetcher
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.data.quotes.single.DefaultSingleQuoteStatusProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface QuoteStatusProducerFactoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindSingleQuoteStatusProducerFactory(
|
||||
impl: DefaultSingleQuoteStatusProducer.Factory,
|
||||
): SingleQuoteStatusProducer.Factory
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package com.tangem.data.quotes.di
|
||||
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteSupplier
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -10,12 +10,12 @@ import javax.inject.Singleton
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal object QuoteSupplierModule {
|
||||
internal object QuoteStatusSupplierModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSingleQuoteSupplier(factory: SingleQuoteProducer.Factory): SingleQuoteSupplier {
|
||||
return object : SingleQuoteSupplier(
|
||||
fun provideSingleQuoteStatusSupplier(factory: SingleQuoteStatusProducer.Factory): SingleQuoteStatusSupplier {
|
||||
return object : SingleQuoteStatusSupplier(
|
||||
factory = factory,
|
||||
keyCreator = { "single_quote_${it.rawCurrencyId.value}" },
|
||||
) {}
|
||||
|
|
@ -4,15 +4,19 @@ import android.content.Context
|
|||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.dataStoreFile
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.quotes.DefaultQuotesRepositoryV2
|
||||
import com.tangem.data.quotes.store.DefaultQuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
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.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.QuotesRepositoryV2
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
|
|
@ -33,8 +37,8 @@ internal object QuotesDataModule {
|
|||
@NetworkMoshi moshi: Moshi,
|
||||
@ApplicationContext context: Context,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): QuotesStoreV2 {
|
||||
return DefaultQuotesStoreV2(
|
||||
): QuotesStatusesStore {
|
||||
return DefaultQuotesStatusesStore(
|
||||
runtimeStore = RuntimeSharedStore(),
|
||||
persistenceDataStore = DataStoreFactory.create(
|
||||
serializer = MoshiDataStoreSerializer(
|
||||
|
|
@ -51,5 +55,23 @@ internal object QuotesDataModule {
|
|||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providesQuotesRepositoryV2(impl: DefaultQuotesRepositoryV2): QuotesRepositoryV2 = impl
|
||||
fun providesQuotesRepository(quotesStatusesStore: QuotesStatusesStore): QuotesRepository {
|
||||
return DefaultQuotesRepository(quotesStatusesStore = quotesStatusesStore)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun bindMultiQuoteUpdater(
|
||||
appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
quotesStatusesStore: QuotesStatusesStore,
|
||||
multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): MultiQuoteUpdater {
|
||||
return DefaultMultiQuoteUpdater(
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStatusesStore = quotesStatusesStore,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
package com.tangem.data.quotes.multi
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.common.api.safeApiCallWithTimeout
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.tokens.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.MultiQuoteFetcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiQuoteFetcher]
|
||||
*
|
||||
* @property tangemTechApi tangemTech api
|
||||
* @property appCurrencyResponseStore app currency response store
|
||||
* @property quotesStore quotes store
|
||||
* @property dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultMultiQuoteFetcher @Inject constructor(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiQuoteFetcher {
|
||||
|
||||
private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter()
|
||||
|
||||
override suspend fun invoke(params: MultiQuoteFetcher.Params) = Either.catchOn(dispatchers.default) {
|
||||
if (params.currenciesIds.isEmpty()) {
|
||||
Timber.d("No currencies to fetch quotes for")
|
||||
return@catchOn
|
||||
}
|
||||
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
|
||||
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(
|
||||
currenciesIds = params.currenciesIds.mapTo(
|
||||
destination = hashSetOf(),
|
||||
transform = CryptoCurrency.RawID::value,
|
||||
),
|
||||
)
|
||||
|
||||
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 updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
|
||||
response = response,
|
||||
filteredIds = replacementIdsResult.idsFiltered,
|
||||
)
|
||||
|
||||
quotesStore.storeActual(values = updatedResponse.quotes)
|
||||
}
|
||||
.onLeft {
|
||||
Timber.e(it)
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
private suspend fun getAppCurrencyId(params: MultiQuoteFetcher.Params): String {
|
||||
val appCurrencyId = params.appCurrencyId
|
||||
?: appCurrencyResponseStore.getSyncOrNull()?.id
|
||||
|
||||
if (appCurrencyId.isNullOrBlank()) {
|
||||
val exception = IllegalStateException("Unable to get AppCurrency for updating quotes")
|
||||
Timber.e(exception)
|
||||
|
||||
throw exception
|
||||
}
|
||||
|
||||
return appCurrencyId
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.tangem.data.quotes.multi
|
||||
|
||||
import arrow.core.Either
|
||||
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.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 timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiQuoteStatusFetcher]
|
||||
*
|
||||
* @property quotesFetcher quotes fetcher
|
||||
* @property appCurrencyResponseStore app currency response store
|
||||
* @property quotesStatusesStore quotes statuses store
|
||||
* @property dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultMultiQuoteStatusFetcher @Inject constructor(
|
||||
private val quotesFetcher: QuotesFetcher,
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiQuoteStatusFetcher {
|
||||
|
||||
override suspend fun invoke(params: MultiQuoteStatusFetcher.Params) = Either.catchOn(dispatchers.default) {
|
||||
if (params.currenciesIds.isEmpty()) {
|
||||
Timber.d("No currencies to fetch quotes for")
|
||||
return@catchOn
|
||||
}
|
||||
|
||||
quotesStatusesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
|
||||
|
||||
val replacementIdsResult = QuotesUnsupportedCurrenciesIdAdapter.replaceUnsupportedCurrencies(
|
||||
currenciesIds = params.currenciesIds.mapTo(
|
||||
destination = hashSetOf(),
|
||||
transform = CryptoCurrency.RawID::value,
|
||||
),
|
||||
)
|
||||
|
||||
val appCurrencyId = getAppCurrencyId(params = params)
|
||||
|
||||
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,
|
||||
filteredIds = replacementIdsResult.idsFiltered,
|
||||
)
|
||||
|
||||
quotesStatusesStore.store(values = updatedResponse.quotes)
|
||||
}
|
||||
.onLeft {
|
||||
Timber.e(it)
|
||||
quotesStatusesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
private suspend fun getAppCurrencyId(params: MultiQuoteStatusFetcher.Params): String {
|
||||
val appCurrencyId = params.appCurrencyId
|
||||
?: appCurrencyResponseStore.getSyncOrNull()?.id
|
||||
|
||||
if (appCurrencyId.isNullOrBlank()) {
|
||||
val exception = IllegalStateException("Unable to get AppCurrency for updating quotes")
|
||||
Timber.e(exception)
|
||||
|
||||
throw exception
|
||||
}
|
||||
|
||||
return appCurrencyId
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,12 @@ package com.tangem.data.quotes.multi
|
|||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import arrow.core.left
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.core.utils.EitherFlow
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.JobHolder
|
||||
import com.tangem.utils.coroutines.saveIn
|
||||
|
|
@ -17,24 +17,21 @@ import kotlinx.coroutines.SupervisorJob
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiQuoteUpdater] which updates quotes when the app currency changes
|
||||
*
|
||||
* @property appCurrencyResponseStore app currency response store
|
||||
* @property quotesStore quotes store
|
||||
* @property multiQuoteFetcher multi quote fetcher
|
||||
* @property quotesStatusesStore quotes store
|
||||
* @property multiQuoteStatusFetcher multi quote status fetcher
|
||||
* @param dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Singleton
|
||||
internal class DefaultMultiQuoteUpdater @Inject constructor(
|
||||
internal class DefaultMultiQuoteUpdater(
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
private val multiQuoteFetcher: MultiQuoteFetcher,
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiQuoteUpdater {
|
||||
|
||||
|
|
@ -60,11 +57,14 @@ internal class DefaultMultiQuoteUpdater @Inject constructor(
|
|||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.mapLatest { appCurrency ->
|
||||
val currenciesIds = quotesStore.getAllSyncOrNull().orEmpty()
|
||||
.mapTo(destination = hashSetOf(), transform = Quote::rawCurrencyId)
|
||||
val currenciesIds = quotesStatusesStore.getAllSyncOrNull().orEmpty()
|
||||
.mapTo(destination = hashSetOf(), transform = QuoteStatus::rawCurrencyId)
|
||||
|
||||
multiQuoteFetcher(
|
||||
params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrency.id),
|
||||
multiQuoteStatusFetcher(
|
||||
params = MultiQuoteStatusFetcher.Params(
|
||||
currenciesIds = currenciesIds,
|
||||
appCurrencyId = appCurrency.id,
|
||||
),
|
||||
)
|
||||
.onLeft(Timber::e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.tangem.data.quotes.repository
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesRepository]
|
||||
*
|
||||
* @property quotesStatusesStore quotes statuses store
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultQuotesRepository(
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
) : QuotesRepository {
|
||||
|
||||
override suspend fun getMultiQuoteSyncOrNull(currenciesIds: Set<CryptoCurrency.RawID>): Set<QuoteStatus> {
|
||||
if (currenciesIds.isEmpty()) {
|
||||
Timber.e("currenciesIds are empty")
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
val storedQuotes = quotesStatusesStore.getAllSyncOrNull()
|
||||
?.filter { it.rawCurrencyId in currenciesIds }
|
||||
|
||||
return currenciesIds.mapTo(hashSetOf()) { currencyId ->
|
||||
storedQuotes?.firstOrNull { it.rawCurrencyId == currencyId }
|
||||
?: QuoteStatus(rawCurrencyId = currencyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteFetcher
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultSingleQuoteFetcher @Inject constructor(
|
||||
private val multiQuoteFetcher: MultiQuoteFetcher,
|
||||
) : SingleQuoteFetcher {
|
||||
|
||||
override suspend fun invoke(params: SingleQuoteFetcher.Params) = multiQuoteFetcher.invoke(
|
||||
MultiQuoteFetcher.Params(
|
||||
currenciesIds = setOf(params.rawCurrencyId),
|
||||
appCurrencyId = params.appCurrencyId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
|
||||
/**
|
||||
* Default implementation of [SingleQuoteProducer]
|
||||
*
|
||||
* @property params params
|
||||
* @property quotesStore quotes store
|
||||
*/
|
||||
internal class DefaultSingleQuoteProducer @AssistedInject constructor(
|
||||
@Assisted val params: SingleQuoteProducer.Params,
|
||||
private val quotesStore: QuotesStoreV2,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : SingleQuoteProducer {
|
||||
|
||||
override val fallback: Quote = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
|
||||
override fun produce(): Flow<Quote> {
|
||||
return quotesStore.get()
|
||||
.mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SingleQuoteProducer.Factory {
|
||||
override fun create(params: SingleQuoteProducer.Params): DefaultSingleQuoteProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [SingleQuoteStatusFetcher]
|
||||
*
|
||||
* @property multiQuoteStatusFetcher fetcher of quotes statuses
|
||||
*/
|
||||
internal class DefaultSingleQuoteStatusFetcher @Inject constructor(
|
||||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher,
|
||||
) : SingleQuoteStatusFetcher {
|
||||
|
||||
override suspend fun invoke(params: SingleQuoteStatusFetcher.Params) = multiQuoteStatusFetcher.invoke(
|
||||
MultiQuoteStatusFetcher.Params(
|
||||
currenciesIds = setOf(params.rawCurrencyId),
|
||||
appCurrencyId = params.appCurrencyId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
|
||||
/**
|
||||
* Default implementation of [SingleQuoteStatusProducer]
|
||||
*
|
||||
* @property params params
|
||||
* @property quotesStatusesStore quotes store
|
||||
*/
|
||||
internal class DefaultSingleQuoteStatusProducer @AssistedInject constructor(
|
||||
@Assisted val params: SingleQuoteStatusProducer.Params,
|
||||
private val quotesStatusesStore: QuotesStatusesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : SingleQuoteStatusProducer {
|
||||
|
||||
override val fallback: QuoteStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
|
||||
override fun produce(): Flow<QuoteStatus> {
|
||||
return quotesStatusesStore.get()
|
||||
.mapNotNull { quotes -> quotes.firstOrNull { it.rawCurrencyId == params.rawCurrencyId } }
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : SingleQuoteStatusProducer.Factory {
|
||||
override fun create(params: SingleQuoteStatusProducer.Params): DefaultSingleQuoteStatusProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
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.datasource.local.quote.converter.QuoteConverter
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -15,21 +15,22 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
internal typealias CurrencyIdWithQuote = Map<String, QuotesResponse.Quote>
|
||||
|
||||
/**
|
||||
* Default implementation of [QuotesStoreV2]
|
||||
* Default implementation of [QuotesStatusesStore]
|
||||
*
|
||||
* @property runtimeStore runtime store
|
||||
* @property persistenceDataStore persistence store
|
||||
* @param dispatchers dispatchers
|
||||
*/
|
||||
internal class DefaultQuotesStoreV2(
|
||||
private val runtimeStore: RuntimeSharedStore<Set<Quote>>,
|
||||
internal class DefaultQuotesStatusesStore(
|
||||
private val runtimeStore: RuntimeSharedStore<Set<QuoteStatus>>,
|
||||
private val persistenceDataStore: DataStore<CurrencyIdWithQuote>,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
) : QuotesStoreV2 {
|
||||
) : QuotesStatusesStore {
|
||||
|
||||
private val scope = CoroutineScope(context = SupervisorJob() + dispatchers.io)
|
||||
|
||||
|
|
@ -40,62 +41,68 @@ internal class DefaultQuotesStoreV2(
|
|||
if (cachedStatuses.isNullOrEmpty()) return@launch
|
||||
|
||||
runtimeStore.store(
|
||||
value = QuoteConverter(isCached = true).convertSet(input = cachedStatuses.entries),
|
||||
value = QuoteStatusConverter(source = StatusSource.CACHE).convertSet(input = cachedStatuses.entries),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun get(): Flow<Set<Quote>> = runtimeStore.get()
|
||||
override fun get(): Flow<Set<QuoteStatus>> = runtimeStore.get()
|
||||
|
||||
override suspend fun getAllSyncOrNull(): Set<Quote>? = runtimeStore.getSyncOrNull()
|
||||
override suspend fun getAllSyncOrNull(): Set<QuoteStatus>? = runtimeStore.getSyncOrNull()
|
||||
|
||||
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,
|
||||
ifNotFound = Quote::Empty,
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
override suspend fun updateStatusSource(
|
||||
currencyId: CryptoCurrency.RawID,
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?,
|
||||
) {
|
||||
updateStatusSource(
|
||||
currenciesIds = setOf(currencyId),
|
||||
source = source,
|
||||
ifNotFound = ifNotFound,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun updateStatusSourceInRuntime(
|
||||
override suspend fun updateStatusSource(
|
||||
currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> Quote? = { null },
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?,
|
||||
) {
|
||||
if (currenciesIds.isEmpty()) {
|
||||
Timber.d("Nothing to update: currencies ids are empty")
|
||||
return
|
||||
}
|
||||
|
||||
runtimeStore.update(default = emptySet()) { stored ->
|
||||
val updatedQuotes = currenciesIds.mapNotNullTo(hashSetOf()) { id ->
|
||||
val quote = stored.firstOrNull { it.rawCurrencyId == id }
|
||||
?: ifNotFound(id)
|
||||
?: return@mapNotNullTo null
|
||||
|
||||
quote.copySealed(source = source)
|
||||
quote.copy(value = quote.value.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 }
|
||||
override suspend fun store(values: CurrencyIdWithQuote) {
|
||||
if (values.isEmpty()) return
|
||||
|
||||
coroutineScope {
|
||||
launch { storeInRuntime(values = values) }
|
||||
launch { storeInPersistence(values = values) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeInPersistenceStore(values: Map<String, QuotesResponse.Quote>) {
|
||||
private suspend fun storeInRuntime(values: CurrencyIdWithQuote) {
|
||||
val quotes = QuoteStatusConverter(source = StatusSource.ACTUAL).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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
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.quote.QuoteStatus
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Store of [QuoteStatus]'es set */
|
||||
internal interface QuotesStatusesStore {
|
||||
|
||||
/** Get flow of quotes */
|
||||
fun get(): Flow<Set<QuoteStatus>>
|
||||
|
||||
/** Get all quotes synchronously or null */
|
||||
suspend fun getAllSyncOrNull(): Set<QuoteStatus>?
|
||||
|
||||
/**
|
||||
* Update [source] of [QuoteStatus] by [currencyId].
|
||||
* If the status is not found, create a new one by [ifNotFound].
|
||||
*/
|
||||
suspend fun updateStatusSource(
|
||||
currencyId: CryptoCurrency.RawID,
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
)
|
||||
|
||||
/**
|
||||
* Update [source] of [QuoteStatus]es by [currenciesIds].
|
||||
* If the status is not found, create a new one by [ifNotFound].
|
||||
*/
|
||||
suspend fun updateStatusSource(
|
||||
currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
source: StatusSource,
|
||||
ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
)
|
||||
|
||||
/**
|
||||
* Store quotes statuses
|
||||
*
|
||||
* @param values map of currency ids and quotes
|
||||
*
|
||||
* See complex methods in `QuotesStatusesStoreExt`.
|
||||
*/
|
||||
suspend fun store(values: Map<String, QuotesResponse.Quote>)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
|
||||
/** Set [StatusSource] of quotes statuses as [StatusSource.CACHE] for [currenciesIds] */
|
||||
internal suspend fun QuotesStatusesStore.setSourceAsCache(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSource(currenciesIds = currenciesIds, source = StatusSource.CACHE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set [StatusSource] of quotes statuses as [StatusSource.ONLY_CACHE] for [currenciesIds].
|
||||
* If the stored status is not found, store a default [QuoteStatus.Empty] status.
|
||||
*/
|
||||
internal suspend fun QuotesStatusesStore.setSourceAsOnlyCache(currenciesIds: Set<CryptoCurrency.RawID>) {
|
||||
updateStatusSource(
|
||||
currenciesIds = currenciesIds,
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
ifNotFound = ::QuoteStatus,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.tangem.data.quotes.store
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.domain.models.currency.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>>
|
||||
|
||||
/** Get all quotes synchronously or null */
|
||||
suspend fun getAllSyncOrNull(): 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>)
|
||||
}
|
||||
|
|
@ -1,23 +1,27 @@
|
|||
package com.tangem.data.tokens.utils
|
||||
package com.tangem.data.quotes.utils
|
||||
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
|
||||
/**
|
||||
* Adapter to replace unsupported currencies for quotes request if it necessary
|
||||
*/
|
||||
class QuotesUnsupportedCurrenciesIdAdapter {
|
||||
/** Adapter to replace unsupported currencies for quotes request if it necessary */
|
||||
internal object QuotesUnsupportedCurrenciesIdAdapter {
|
||||
|
||||
/**
|
||||
* Replaces unsupported currencies id to it replacements for request
|
||||
*/
|
||||
/** Ethereum coin ID */
|
||||
private val ethCoinId = Blockchain.Ethereum.toCoinId()
|
||||
|
||||
/** Map that contains unsupported currencies and their replacement for request */
|
||||
private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId }
|
||||
|
||||
/** Replaces unsupported currencies id [currenciesIds] to it replacements for request */
|
||||
fun replaceUnsupportedCurrencies(currenciesIds: Set<String>): ReplacementResult {
|
||||
val idsForRequest = mutableSetOf<String>()
|
||||
val idsFiltered = mutableSetOf<String>()
|
||||
|
||||
currenciesIds.forEach { currencyId ->
|
||||
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[currencyId]
|
||||
|
||||
if (replacementId != null) {
|
||||
idsFiltered.add(currencyId)
|
||||
idsForRequest.add(replacementId)
|
||||
|
|
@ -25,16 +29,17 @@ class QuotesUnsupportedCurrenciesIdAdapter {
|
|||
idsForRequest.add(currencyId)
|
||||
}
|
||||
}
|
||||
|
||||
return ReplacementResult(idsForRequest = idsForRequest, idsFiltered = idsFiltered)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover previously replaced currencies to response that uses in application
|
||||
*/
|
||||
/** Recover previously replaced currencies [filteredIds] to [response] that uses in application */
|
||||
fun getResponseWithUnsupportedCurrencies(response: QuotesResponse, filteredIds: Set<String>): QuotesResponse {
|
||||
val updatedQuotes = mutableMapOf<String, QuotesResponse.Quote>()
|
||||
|
||||
filteredIds.forEach { filteredId ->
|
||||
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[filteredId]
|
||||
|
||||
if (replacementId != null) {
|
||||
val quoteReplacement = response.quotes[replacementId]
|
||||
if (quoteReplacement != null) {
|
||||
|
|
@ -42,16 +47,9 @@ class QuotesUnsupportedCurrenciesIdAdapter {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.copy(quotes = updatedQuotes + response.quotes)
|
||||
}
|
||||
|
||||
data class ReplacementResult(val idsForRequest: Set<String>, val idsFiltered: Set<String>)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Map that contains unsupported currencies and their replacement for request
|
||||
*/
|
||||
private val ethCoinId = Blockchain.Ethereum.toCoinId()
|
||||
private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = l2BlockchainsCoinIds.associateWith { ethCoinId }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package com.tangem.data.quotes.converter
|
||||
|
||||
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.utils.ProvideTestModels
|
||||
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.quote.QuoteStatus
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuoteStatusConverterTest {
|
||||
|
||||
private val ethQuoteDM = "ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE)
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun convert(model: ConvertTestModel) {
|
||||
// Act
|
||||
val actual = QuoteStatusConverter(source = model.source).convert(value = model.value)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
ConvertTestModel(
|
||||
source = StatusSource.CACHE,
|
||||
value = createEntry(value = ethQuoteDM),
|
||||
expected = ethQuoteDM.toDomain(source = StatusSource.CACHE),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
value = createEntry(value = ethQuoteDM),
|
||||
expected = ethQuoteDM.toDomain(source = StatusSource.ONLY_CACHE),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(value = ethQuoteDM),
|
||||
expected = ethQuoteDM.toDomain(source = StatusSource.ACTUAL),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(
|
||||
value = "ETH" to QuotesResponse.Quote(
|
||||
price = null,
|
||||
priceChange24h = null,
|
||||
priceChange1w = null,
|
||||
priceChange30d = null,
|
||||
),
|
||||
),
|
||||
expected = QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID("ETH"),
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ZERO,
|
||||
priceChange = BigDecimal("0.00"),
|
||||
),
|
||||
),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(
|
||||
value = "ETH" to QuotesResponse.Quote(
|
||||
price = null,
|
||||
priceChange24h = null,
|
||||
priceChange1w = BigDecimal.ZERO,
|
||||
priceChange30d = BigDecimal.ZERO,
|
||||
),
|
||||
),
|
||||
expected = QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID("ETH"),
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ZERO,
|
||||
priceChange = BigDecimal("0.00"),
|
||||
),
|
||||
),
|
||||
),
|
||||
ConvertTestModel(
|
||||
source = StatusSource.ACTUAL,
|
||||
value = createEntry(
|
||||
value = "ETH" to QuotesResponse.Quote(
|
||||
price = BigDecimal.ONE,
|
||||
priceChange24h = BigDecimal.ONE,
|
||||
priceChange1w = null,
|
||||
priceChange30d = null,
|
||||
),
|
||||
),
|
||||
expected = QuoteStatus(
|
||||
rawCurrencyId = CryptoCurrency.RawID("ETH"),
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal("0.01"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun createEntry(value: Pair<String, QuotesResponse.Quote>): Map.Entry<String, QuotesResponse.Quote> {
|
||||
return mapOf(value).entries.first()
|
||||
}
|
||||
|
||||
data class ConvertTestModel(
|
||||
val source: StatusSource,
|
||||
val value: Map.Entry<String, QuotesResponse.Quote>,
|
||||
val expected: QuoteStatus,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
package com.tangem.data.quotes.multi
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
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.MultiQuoteFetcher
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiQuoteFetcherTest {
|
||||
|
||||
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
|
||||
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>(relaxed = true)
|
||||
private val quotesStore = mockk<QuotesStoreV2>(relaxed = true)
|
||||
|
||||
private val fetcher = DefaultMultiQuoteFetcher(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStore = quotesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fetch quotes successfully`() = runTest {
|
||||
val params = MultiQuoteFetcher.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 actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch quotes successfully if currenciesIds from params is empty`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = null)
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.refresh(currenciesIds = any())
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch quotes successfully if appCurrencyId from params is not null`() = runTest {
|
||||
val appCurrencyId = "usd"
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
|
||||
|
||||
val coinIds = "BTC,ETH"
|
||||
coEvery {
|
||||
tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds)
|
||||
} returns ApiResponse.Success(successResponse)
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch quotes failure because appCurrencyId from params is blank`() = runTest {
|
||||
val appCurrencyId = ""
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = appCurrencyId)
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(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 {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
val coinIds = "BTC,ETH"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse<QuotesResponse>
|
||||
coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeActual(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch quotes failure because app currency not found`() = runTest {
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null
|
||||
|
||||
val actual = fetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = params.currenciesIds)
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
quotesStore.storeError(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
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),
|
||||
"ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
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.ApiResponseError
|
||||
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.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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 quotesFetcher = mockk<QuotesFetcher>()
|
||||
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>()
|
||||
private val quotesStore = mockk<QuotesStatusesStore>(relaxed = true)
|
||||
|
||||
private val fetcher = DefaultMultiQuoteStatusFetcher(
|
||||
quotesFetcher = quotesFetcher,
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStatusesStore = quotesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(quotesFetcher, appCurrencyResponseStore, quotesStore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
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()
|
||||
quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields)
|
||||
quotesStore.store(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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()
|
||||
quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any())
|
||||
quotesStore.store(values = 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)
|
||||
|
||||
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)
|
||||
quotesFetcher.fetch(fiatCurrencyId = appCurrencyId, currenciesIds = currenciesIds, fields = fields)
|
||||
quotesStore.store(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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()
|
||||
quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields)
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.store(values = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
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()
|
||||
quotesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesFetcher.fetch(fiatCurrencyId = any(), currenciesIds = any(), fields = any())
|
||||
quotesStore.store(values = any())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val currenciesIds = setOf(
|
||||
CryptoCurrency.RawID(value = "BTC"),
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
)
|
||||
|
||||
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),
|
||||
"ETH" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.TEN),
|
||||
),
|
||||
)
|
||||
|
||||
val fields = setOf(QuotesFetcher.Field.PRICE, QuotesFetcher.Field.PRICE_CHANGE_24H)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@ package com.tangem.data.quotes.multi
|
|||
import arrow.core.right
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
|
||||
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteFetcher
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
|
@ -16,16 +16,16 @@ import org.junit.Test
|
|||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiQuoteUpdaterTest {
|
||||
internal class DefaultMultiQuoteStatusUpdaterTest {
|
||||
|
||||
private val appCurrencyResponseStore: AppCurrencyResponseStore = mockk()
|
||||
private val quotesStore: QuotesStoreV2 = mockk()
|
||||
private val multiQuoteFetcher: MultiQuoteFetcher = mockk()
|
||||
private val quotesStore: QuotesStatusesStore = mockk()
|
||||
private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher = mockk()
|
||||
|
||||
private val multiQuoteUpdater = DefaultMultiQuoteUpdater(
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStore = quotesStore,
|
||||
multiQuoteFetcher = multiQuoteFetcher,
|
||||
quotesStatusesStore = quotesStore,
|
||||
multiQuoteStatusFetcher = multiQuoteStatusFetcher,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
|
|
@ -36,8 +36,8 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
|
||||
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
|
||||
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteFetcher(params) } returns Unit.right()
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteStatusFetcher(params) } returns Unit.right()
|
||||
|
||||
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerifyOrder {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(params)
|
||||
multiQuoteStatusFetcher(params)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,8 +59,8 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
|
||||
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
|
||||
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteFetcher(params) } returns Unit.right()
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteStatusFetcher(params) } returns Unit.right()
|
||||
|
||||
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerifyOrder {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(params)
|
||||
multiQuoteStatusFetcher(params)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(any())
|
||||
multiQuoteStatusFetcher(any())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,8 +111,8 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
every { appCurrencyResponseStore.get() } returns appCurrencyFlow
|
||||
coEvery { quotesStore.getAllSyncOrNull() } returns emptySet()
|
||||
|
||||
val params = MultiQuoteFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteFetcher(params) } returns Unit.right()
|
||||
val params = MultiQuoteStatusFetcher.Params(currenciesIds = emptySet(), appCurrencyId = usdAppCurrency.id)
|
||||
coEvery { multiQuoteStatusFetcher(params) } returns Unit.right()
|
||||
|
||||
val actual = multiQuoteUpdater.getMultiQuoteUpdatesFlow()
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(any())
|
||||
multiQuoteStatusFetcher(any())
|
||||
}
|
||||
|
||||
innerFlow.emit(value = true)
|
||||
|
|
@ -136,7 +136,7 @@ internal class DefaultMultiQuoteUpdaterTest {
|
|||
|
||||
coVerifyOrder {
|
||||
quotesStore.getAllSyncOrNull()
|
||||
multiQuoteFetcher(params)
|
||||
multiQuoteStatusFetcher(params)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.tangem.data.quotes.repository
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
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.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.QuotesRepository
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultQuotesRepositoryTest {
|
||||
|
||||
private val quotesStatusesStore = mockk<QuotesStatusesStore>()
|
||||
private val repository: QuotesRepository = DefaultQuotesRepository(quotesStatusesStore = quotesStatusesStore)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(quotesStatusesStore)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class GetMultiQuoteSyncOrNull {
|
||||
|
||||
private val btcRawId = CryptoCurrency.RawID(value = "BTC")
|
||||
private val ethRawId = CryptoCurrency.RawID(value = "ETH")
|
||||
|
||||
private val ethQuote = QuoteStatus(
|
||||
rawCurrencyId = ethRawId,
|
||||
value = QuoteStatus.Data(
|
||||
source = StatusSource.ACTUAL,
|
||||
fiatRate = BigDecimal.ZERO,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
),
|
||||
)
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun getMultiQuoteSyncOrNull(model: GetMultiQuoteSyncOrNullModel) = runTest {
|
||||
// Arrange
|
||||
coEvery { quotesStatusesStore.getAllSyncOrNull() } returns model.initialStore
|
||||
|
||||
// Act
|
||||
val actual = repository.getMultiQuoteSyncOrNull(currenciesIds = model.currencyIds)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = null,
|
||||
currencyIds = emptySet(),
|
||||
expected = emptySet(),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = null,
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = emptySet(),
|
||||
currencyIds = emptySet(),
|
||||
expected = emptySet(),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = emptySet(),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = setOf(ethQuote),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(ethQuote),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = setOf(QuoteStatus(rawCurrencyId = btcRawId)),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(QuoteStatus(rawCurrencyId = ethRawId)),
|
||||
),
|
||||
GetMultiQuoteSyncOrNullModel(
|
||||
initialStore = setOf(ethQuote, QuoteStatus(rawCurrencyId = btcRawId)),
|
||||
currencyIds = setOf(ethRawId),
|
||||
expected = setOf(ethQuote),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class GetMultiQuoteSyncOrNullModel(
|
||||
val initialStore: Set<QuoteStatus>?,
|
||||
val currencyIds: Set<CryptoCurrency.RawID>,
|
||||
val expected: Set<QuoteStatus>?,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
package com.tangem.data.quotes.single
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.data.quotes.multi.DefaultMultiQuoteFetcher
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
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.single.SingleQuoteFetcher
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.coVerifyOrder
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.math.BigDecimal
|
||||
|
||||
internal class DefaultSingleQuoteFetcherTest {
|
||||
|
||||
private val tangemTechApi = mockk<TangemTechApi>(relaxed = true)
|
||||
private val appCurrencyResponseStore = mockk<AppCurrencyResponseStore>(relaxed = true)
|
||||
private val quotesStore = mockk<QuotesStoreV2>(relaxed = true)
|
||||
|
||||
private val multiFetcher = DefaultMultiQuoteFetcher(
|
||||
tangemTechApi = tangemTechApi,
|
||||
appCurrencyResponseStore = appCurrencyResponseStore,
|
||||
quotesStore = quotesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
private val singleFetcher = DefaultSingleQuoteFetcher(multiFetcher)
|
||||
|
||||
@Test
|
||||
fun `fetch single quote successfully`() = runTest {
|
||||
val params = SingleQuoteFetcher.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.refresh(currenciesIds = setOf(params.rawCurrencyId))
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeError(currenciesIds = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch single quote successfully if appCurrencyId from params is not null`() = runTest {
|
||||
val appCurrencyId = "usd"
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId)
|
||||
|
||||
val coinIds = "BTC"
|
||||
coEvery {
|
||||
tangemTechApi.getQuotes(currencyId = appCurrencyId, coinIds = coinIds)
|
||||
} returns ApiResponse.Success(successResponse)
|
||||
|
||||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
quotesStore.storeActual(values = successResponse.quotes)
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isRight()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch single quote failure because appCurrencyId from params is blank`() = runTest {
|
||||
val appCurrencyId = ""
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = appCurrencyId)
|
||||
|
||||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
quotesStore.storeError(currenciesIds = setOf(currenciesId))
|
||||
}
|
||||
|
||||
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 = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
|
||||
|
||||
val coinIds = "BTC"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val errorResponse = ApiResponse.Error(ApiResponseError.NetworkException) as ApiResponse<QuotesResponse>
|
||||
coEvery { tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds) } returns errorResponse
|
||||
|
||||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
tangemTechApi.getQuotes(currencyId = "usd", coinIds = coinIds)
|
||||
quotesStore.storeError(currenciesIds = setOf(currenciesId))
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
quotesStore.storeActual(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch single quote failure because app currency not found`() = runTest {
|
||||
val params = SingleQuoteFetcher.Params(rawCurrencyId = currenciesId, appCurrencyId = null)
|
||||
|
||||
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns null
|
||||
|
||||
val actual = singleFetcher(params)
|
||||
|
||||
coVerifyOrder {
|
||||
quotesStore.refresh(currenciesIds = setOf(currenciesId))
|
||||
appCurrencyResponseStore.getSyncOrNull()
|
||||
quotesStore.storeError(currenciesIds = setOf(currenciesId))
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
tangemTechApi.getQuotes(currencyId = any(), coinIds = any())
|
||||
quotesStore.storeActual(values = any())
|
||||
}
|
||||
|
||||
Truth.assertThat(actual.isLeft()).isTrue()
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultSingleQuoteStatusFetcherTest {
|
||||
|
||||
private val multiFetcher = mockk<MultiQuoteStatusFetcher>()
|
||||
private val singleFetcher = DefaultSingleQuoteStatusFetcher(multiFetcher)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(multiFetcher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if multiFetcher returns success`() = runTest {
|
||||
// Arrange
|
||||
val params = SingleQuoteStatusFetcher.Params(rawCurrencyId = currencyId, appCurrencyId = null)
|
||||
|
||||
val multiFetcherParams = MultiQuoteStatusFetcher.Params(
|
||||
currenciesIds = setOf(params.rawCurrencyId),
|
||||
appCurrencyId = params.appCurrencyId,
|
||||
)
|
||||
|
||||
coEvery { multiFetcher(multiFetcherParams) } returns Unit.right()
|
||||
|
||||
// Act
|
||||
val actual = singleFetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
|
||||
coVerify(exactly = 1) { multiFetcher(multiFetcherParams) }
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
|
||||
// Assert
|
||||
val expected = multiFetcherError
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
|
||||
coVerify(exactly = 1) { multiFetcher(multiFetcherParams) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
val currencyId = CryptoCurrency.RawID(value = "BTC")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@ package com.tangem.data.quotes.single
|
|||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
import com.tangem.data.quotes.store.QuotesStoreV2
|
||||
import com.tangem.data.quotes.store.QuotesStatusesStore
|
||||
import com.tangem.domain.models.StatusSource
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.quotes.single.SingleQuoteProducer
|
||||
import com.tangem.domain.tokens.model.Quote
|
||||
import com.tangem.domain.models.quote.QuoteStatus
|
||||
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
|
@ -19,27 +19,27 @@ import java.math.BigDecimal
|
|||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultSingleQuoteProducerTest {
|
||||
internal class DefaultSingleQuoteStatusProducerTest {
|
||||
|
||||
private val params = SingleQuoteProducer.Params(
|
||||
private val params = SingleQuoteStatusProducer.Params(
|
||||
rawCurrencyId = CryptoCurrency.RawID(value = "BTC"),
|
||||
)
|
||||
|
||||
private val quotesStore = mockk<QuotesStoreV2>()
|
||||
private val quotesStore = mockk<QuotesStatusesStore>()
|
||||
|
||||
private val producer = DefaultSingleQuoteProducer(
|
||||
private val producer = DefaultSingleQuoteStatusProducer(
|
||||
params = params,
|
||||
quotesStore = quotesStore,
|
||||
quotesStatusesStore = quotesStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test that flow is mapped for network from params`() = runTest {
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
val storeQuote = flowOf(
|
||||
setOf(
|
||||
status,
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
|
||||
@Test
|
||||
fun `test that flow is updated if quote is updated`() = runTest {
|
||||
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
|
||||
val storeQuote = MutableSharedFlow<Set<QuoteStatus>>(replay = 2, extraBufferCapacity = 1)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
verify { quotesStore.get() }
|
||||
|
||||
// first emit
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
storeQuote.emit(value = setOf(status))
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
|
@ -75,11 +75,13 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
Truth.assertThat(values1).isEqualTo(listOf(status))
|
||||
|
||||
// second emit
|
||||
val updatedStatus = Quote.Value(
|
||||
val updatedStatus = QuoteStatus(
|
||||
rawCurrencyId = params.rawCurrencyId,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
value = QuoteStatus.Data(
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
)
|
||||
storeQuote.emit(value = setOf(updatedStatus))
|
||||
|
||||
|
|
@ -91,7 +93,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
|
||||
@Test
|
||||
fun `test that flow is filtered the same status`() = runTest {
|
||||
val storeQuote = MutableSharedFlow<Set<Quote>>(replay = 2, extraBufferCapacity = 1)
|
||||
val storeQuote = MutableSharedFlow<Set<QuoteStatus>>(replay = 2, extraBufferCapacity = 1)
|
||||
|
||||
every { quotesStore.get() } returns storeQuote
|
||||
|
||||
|
|
@ -100,7 +102,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
verify { quotesStore.get() }
|
||||
|
||||
// first emit
|
||||
val status = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
storeQuote.emit(value = setOf(status))
|
||||
|
||||
val values1 = getEmittedValues(flow = actual)
|
||||
|
|
@ -120,11 +122,13 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
@Test
|
||||
fun `test if flow throws exception`() = runTest {
|
||||
val exception = IllegalStateException()
|
||||
val status = Quote.Value(
|
||||
val status = QuoteStatus(
|
||||
rawCurrencyId = params.rawCurrencyId,
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
value = QuoteStatus.Data(
|
||||
fiatRate = BigDecimal.ONE,
|
||||
priceChange = BigDecimal.ZERO,
|
||||
source = StatusSource.ACTUAL,
|
||||
),
|
||||
)
|
||||
|
||||
val innerFlow = MutableStateFlow(value = false)
|
||||
|
|
@ -146,7 +150,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
val values1 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values1.size).isEqualTo(1)
|
||||
val fallbackStatus = Quote.Empty(rawCurrencyId = params.rawCurrencyId)
|
||||
val fallbackStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
|
||||
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
|
||||
|
||||
innerFlow.emit(value = true)
|
||||
|
|
@ -160,7 +164,7 @@ internal class DefaultSingleQuoteProducerTest {
|
|||
fun `test if flow doesn't contain network from params`() = runTest {
|
||||
val storeFlow = flowOf(
|
||||
setOf(
|
||||
Quote.Empty(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ETH")),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
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.ProvideTestModels
|
||||
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.quote.QuoteStatus
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuotesStatusesStoreExtTest {
|
||||
|
||||
private var runtimeStore: RuntimeSharedStore<Set<QuoteStatus>> by Delegates.notNull()
|
||||
private var persistenceStore: MockStateDataStore<CurrencyIdWithQuote> by Delegates.notNull()
|
||||
private var store: DefaultQuotesStatusesStore by Delegates.notNull()
|
||||
|
||||
private val btcQuoteDM = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
private val btcQuote = btcQuoteDM.toDomain()
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
runtimeStore = RuntimeSharedStore()
|
||||
persistenceStore = MockStateDataStore(default = emptyMap())
|
||||
|
||||
store = DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class SetSourceAsCache {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun setSourceAsCache(model: SetSourceTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.setSourceAsCache(model.currenciesIds)
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
SetSourceTestModel(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = emptySet(),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.CACHE)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class SetSourceAsOnlyCache {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun setSourceAsOnlyCache(model: SetSourceTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.setSourceAsOnlyCache(model.currenciesIds)
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
SetSourceTestModel(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(QuoteStatus(btcQuote.rawCurrencyId)),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)),
|
||||
),
|
||||
SetSourceTestModel(
|
||||
initialRuntime = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
expected = setOf(btcQuoteDM.toDomain(source = StatusSource.ONLY_CACHE)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class SetSourceTestModel(
|
||||
val initialRuntime: Set<QuoteStatus>?,
|
||||
val currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
val expected: Set<QuoteStatus>?,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,565 @@
|
|||
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.common.test.utils.ProvideTestModels
|
||||
import com.tangem.common.test.utils.getEmittedValues
|
||||
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.quote.QuoteStatus
|
||||
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.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuotesStatusesStoreTest {
|
||||
|
||||
private var runtimeStore: RuntimeSharedStore<Set<QuoteStatus>> by Delegates.notNull()
|
||||
private var persistenceStore: MockStateDataStore<CurrencyIdWithQuote> by Delegates.notNull()
|
||||
private var store: DefaultQuotesStatusesStore by Delegates.notNull()
|
||||
|
||||
// region Data models
|
||||
private val btcQuoteDM = "BTC" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
|
||||
private val ethQuoteDM = "ETH" to MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
private val btcQuote = btcQuoteDM.toDomain()
|
||||
private val ethQuote = ethQuoteDM.toDomain()
|
||||
private val adaEmptyQuote = QuoteStatus(rawCurrencyId = CryptoCurrency.RawID(value = "ADA"))
|
||||
// endregion
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
runtimeStore = RuntimeSharedStore()
|
||||
persistenceStore = MockStateDataStore(default = emptyMap())
|
||||
|
||||
store = DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Initialization {
|
||||
|
||||
@Test
|
||||
fun `initialization if cache store is empty`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
|
||||
val persistenceStore: DataStore<CurrencyIdWithQuote> = mockk()
|
||||
|
||||
every { persistenceStore.data } returns emptyFlow()
|
||||
|
||||
// Act
|
||||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = null
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialization if cache store contains empty map`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
// Act
|
||||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = null
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initialization if cache store is not empty`() = runTest {
|
||||
// Arrange
|
||||
val runtimeStore = RuntimeSharedStore<Set<QuoteStatus>>()
|
||||
val persistenceStore = MockStateDataStore<CurrencyIdWithQuote>(default = emptyMap())
|
||||
|
||||
persistenceStore.updateData {
|
||||
it.toMutableMap().apply {
|
||||
this += btcQuoteDM
|
||||
this += ethQuoteDM
|
||||
}
|
||||
}
|
||||
|
||||
// Act
|
||||
DefaultQuotesStatusesStore(
|
||||
runtimeStore = runtimeStore,
|
||||
persistenceDataStore = persistenceStore,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
val actual = runtimeStore.getSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = setOf(
|
||||
btcQuoteDM.toDomain(source = StatusSource.CACHE),
|
||||
ethQuoteDM.toDomain(source = StatusSource.CACHE),
|
||||
)
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Get {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun get(model: GetTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
val actual = getEmittedValues(flow = store.get())
|
||||
|
||||
// Assert
|
||||
val expected = listOfNotNull(model.expected)
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetTestModel(initialRuntime = null, expected = null),
|
||||
GetTestModel(initialRuntime = emptySet(), expected = emptySet()),
|
||||
GetTestModel(initialRuntime = setOf(btcQuote, ethQuote), expected = setOf(btcQuote, ethQuote)),
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class GetAllSyncOrNull {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun getAllSyncOrNull(model: GetTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetTestModel(initialRuntime = null, expected = null),
|
||||
GetTestModel(initialRuntime = emptySet(), expected = emptySet()),
|
||||
GetTestModel(initialRuntime = setOf(btcQuote, ethQuote), expected = setOf(btcQuote, ethQuote)),
|
||||
)
|
||||
}
|
||||
|
||||
data class GetTestModel(val initialRuntime: Set<QuoteStatus>?, val expected: Set<QuoteStatus>?)
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class SingleUpdateStatusSource {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun updateStatusSource(model: UpdateStatusSourceModel.Single) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.updateStatusSource(
|
||||
currencyId = model.currencyId,
|
||||
source = model.source,
|
||||
ifNotFound = model.ifNotFound,
|
||||
)
|
||||
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// region runtime store is null
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = null,
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = null,
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store is empty
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = emptySet(),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = emptySet(),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store contains statuses
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote, ethQuote, adaEmptyQuote),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.CACHE)),
|
||||
ethQuote,
|
||||
adaEmptyQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote, ethQuote),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.ONLY_CACHE)),
|
||||
ethQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote, ethQuote),
|
||||
currencyId = btcQuote.rawCurrencyId,
|
||||
source = StatusSource.ACTUAL,
|
||||
expected = setOf(btcQuote, ethQuote),
|
||||
),
|
||||
UpdateStatusSourceModel.Single(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currencyId = adaEmptyQuote.rawCurrencyId,
|
||||
source = StatusSource.ACTUAL,
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(btcQuote, adaEmptyQuote),
|
||||
),
|
||||
// endregion
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class MultiUpdateStatusSource {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun updateStatusSource(model: UpdateStatusSourceModel.Multi) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
// Act
|
||||
store.updateStatusSource(
|
||||
currenciesIds = model.currenciesIds,
|
||||
source = model.source,
|
||||
ifNotFound = model.ifNotFound,
|
||||
)
|
||||
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// region runtime store is null
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(),
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = null,
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(
|
||||
btcQuote.rawCurrencyId,
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
),
|
||||
source = StatusSource.ACTUAL, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = null,
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store is empty
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(),
|
||||
source = StatusSource.CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(
|
||||
btcQuote.rawCurrencyId,
|
||||
CryptoCurrency.RawID(value = "ETH"),
|
||||
),
|
||||
source = StatusSource.ACTUAL, // never-mind
|
||||
expected = emptySet(),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = emptySet(),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE, // never-mind
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(
|
||||
QuoteStatus(rawCurrencyId = btcQuote.rawCurrencyId),
|
||||
),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store contains statuses
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote, adaEmptyQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.CACHE)),
|
||||
adaEmptyQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote, adaEmptyQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ONLY_CACHE,
|
||||
expected = setOf(
|
||||
btcQuote.copy(value = btcQuote.value.copySealed(source = StatusSource.ONLY_CACHE)),
|
||||
adaEmptyQuote,
|
||||
),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote, adaEmptyQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ACTUAL,
|
||||
expected = setOf(btcQuote, adaEmptyQuote),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ACTUAL,
|
||||
expected = setOf(btcQuote),
|
||||
),
|
||||
UpdateStatusSourceModel.Multi(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
currenciesIds = setOf(btcQuote.rawCurrencyId, adaEmptyQuote.rawCurrencyId),
|
||||
source = StatusSource.ACTUAL,
|
||||
ifNotFound = ::QuoteStatus,
|
||||
expected = setOf(btcQuote, adaEmptyQuote),
|
||||
),
|
||||
// endregion
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface UpdateStatusSourceModel {
|
||||
|
||||
val initialRuntime: Set<QuoteStatus>?
|
||||
val source: StatusSource
|
||||
val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus?
|
||||
val expected: Set<QuoteStatus>?
|
||||
|
||||
data class Single(
|
||||
override val initialRuntime: Set<QuoteStatus>?,
|
||||
val currencyId: CryptoCurrency.RawID,
|
||||
override val source: StatusSource,
|
||||
override val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
override val expected: Set<QuoteStatus>?,
|
||||
) : UpdateStatusSourceModel
|
||||
|
||||
data class Multi(
|
||||
override val initialRuntime: Set<QuoteStatus>?,
|
||||
val currenciesIds: Set<CryptoCurrency.RawID>,
|
||||
override val source: StatusSource,
|
||||
override val ifNotFound: (CryptoCurrency.RawID) -> QuoteStatus? = { null },
|
||||
override val expected: Set<QuoteStatus>?,
|
||||
) : UpdateStatusSourceModel
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Store {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun store(model: StoreTestModel) = runTest {
|
||||
// Arrange
|
||||
if (model.initialRuntime != null) {
|
||||
runtimeStore.store(value = model.initialRuntime)
|
||||
}
|
||||
|
||||
if (model.initialPersistence != null) {
|
||||
persistenceStore.updateData { model.initialPersistence }
|
||||
}
|
||||
|
||||
// Act
|
||||
store.store(values = model.values)
|
||||
|
||||
val runtimeActual = runtimeStore.getSyncOrNull()
|
||||
val persistenceActual = getEmittedValues(persistenceStore.data)
|
||||
|
||||
// Assert
|
||||
val runtimeExpected = model.runtimeExpected
|
||||
val persistenceExpected = listOf(model.persistenceExpected)
|
||||
|
||||
Truth.assertThat(runtimeActual).isEqualTo(runtimeExpected)
|
||||
Truth.assertThat(persistenceActual).isEqualTo(persistenceExpected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
// region stores are null
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = null,
|
||||
values = emptyMap(),
|
||||
runtimeExpected = null,
|
||||
persistenceExpected = emptyMap(),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = null,
|
||||
values = mapOf(btcQuoteDM),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region persistence store is null
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
initialPersistence = null,
|
||||
values = emptyMap(),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = emptyMap(),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(ethQuote),
|
||||
initialPersistence = null,
|
||||
values = mapOf(btcQuoteDM),
|
||||
runtimeExpected = setOf(ethQuote, btcQuote),
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region runtime store is null
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = mapOf(btcQuoteDM),
|
||||
values = emptyMap(),
|
||||
runtimeExpected = null,
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = null,
|
||||
initialPersistence = mapOf(ethQuoteDM),
|
||||
values = mapOf(btcQuoteDM),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
|
||||
// region stores contain data
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
initialPersistence = mapOf(btcQuoteDM),
|
||||
values = emptyMap(),
|
||||
runtimeExpected = setOf(btcQuote),
|
||||
persistenceExpected = mapOf(btcQuoteDM),
|
||||
),
|
||||
StoreTestModel(
|
||||
initialRuntime = setOf(btcQuote),
|
||||
initialPersistence = mapOf(btcQuoteDM),
|
||||
values = mapOf(ethQuoteDM),
|
||||
runtimeExpected = setOf(ethQuote, btcQuote),
|
||||
persistenceExpected = mapOf(ethQuoteDM, btcQuoteDM),
|
||||
),
|
||||
// endregion
|
||||
)
|
||||
}
|
||||
|
||||
data class StoreTestModel(
|
||||
val initialRuntime: Set<QuoteStatus>?,
|
||||
val initialPersistence: CurrencyIdWithQuote?,
|
||||
val values: CurrencyIdWithQuote,
|
||||
val persistenceExpected: CurrencyIdWithQuote?,
|
||||
val runtimeExpected: Set<QuoteStatus>?,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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 = getEmittedValues(flow = 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 = getEmittedValues(flow = 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 = getEmittedValues(flow = actual)
|
||||
|
||||
Truth.assertThat(values.size).isEqualTo(1)
|
||||
Truth.assertThat(values).isEqualTo(listOf(setOf(btcQuote.toDomain(), ethQuote.toDomain())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getAllSyncOrNull if runtime store is empty`() = runTest {
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getAllSyncOrNull if runtime store contains empty set`() = runTest {
|
||||
runtimeStore.store(value = emptySet())
|
||||
|
||||
val actual = store.getAllSyncOrNull()
|
||||
|
||||
Truth.assertThat(actual).isEqualTo(emptySet<Quote>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test getAllSyncOrNull 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.getAllSyncOrNull()
|
||||
|
||||
val expected = setOf(btcQuote.toDomain(), ethQuote.toDomain())
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
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.models.currency.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)
|
||||
|
||||
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(emptySet<Quote>())
|
||||
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>>())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
package com.tangem.data.quotes.utils
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.compatibility.l2BlockchainsCoinIds
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.data.quotes.utils.QuotesUnsupportedCurrenciesIdAdapter.ReplacementResult
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.QuotesResponse.Quote
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class QuotesUnsupportedCurrenciesIdAdapterTest {
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class ReplaceUnsupportedCurrencies {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun replaceUnsupportedCurrencies(model: ReplaceUnsupportedCurrenciesModel) {
|
||||
// Act
|
||||
val actual = QuotesUnsupportedCurrenciesIdAdapter.replaceUnsupportedCurrencies(
|
||||
currenciesIds = model.currenciesIds,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
ReplaceUnsupportedCurrenciesModel(
|
||||
currenciesIds = l2BlockchainsCoinIds.toSet(),
|
||||
expected = ReplacementResult(
|
||||
idsForRequest = l2BlockchainsCoinIds.mapTo(hashSetOf()) {
|
||||
Blockchain.Ethereum.toCoinId()
|
||||
},
|
||||
idsFiltered = l2BlockchainsCoinIds.toSet(),
|
||||
),
|
||||
),
|
||||
ReplaceUnsupportedCurrenciesModel(
|
||||
currenciesIds = notL2BlockchainsCoinIds,
|
||||
expected = ReplacementResult(idsForRequest = notL2BlockchainsCoinIds, idsFiltered = emptySet()),
|
||||
),
|
||||
ReplaceUnsupportedCurrenciesModel(
|
||||
currenciesIds = blockchainCoinIds,
|
||||
expected = ReplacementResult(
|
||||
idsForRequest = Blockchain.entries
|
||||
.filterNot(Blockchain::isTestnet)
|
||||
.mapTo(hashSetOf()) {
|
||||
if (it.isL2EthereumNetwork()) {
|
||||
Blockchain.Ethereum.toCoinId()
|
||||
} else {
|
||||
it.toCoinId()
|
||||
}
|
||||
},
|
||||
idsFiltered = l2BlockchainsCoinIds.toSet(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class ReplaceUnsupportedCurrenciesModel(
|
||||
val currenciesIds: Set<String>,
|
||||
val expected: ReplacementResult,
|
||||
)
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class GetResponseWithUnsupportedCurrencies {
|
||||
|
||||
private val zeroQuote = MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ZERO)
|
||||
private val ethQuote = "ethereum" to zeroQuote
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun getResponseWithUnsupportedCurrencies(model: GetResponseWithUnsupportedCurrenciesModel) {
|
||||
// Act
|
||||
val actual = QuotesUnsupportedCurrenciesIdAdapter.getResponseWithUnsupportedCurrencies(
|
||||
response = model.response,
|
||||
filteredIds = model.filteredIds,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val expected = model.expected
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
GetResponseWithUnsupportedCurrenciesModel(
|
||||
response = QuotesResponse(quotes = emptyMap()),
|
||||
filteredIds = emptySet(),
|
||||
expected = QuotesResponse(quotes = emptyMap()),
|
||||
),
|
||||
GetResponseWithUnsupportedCurrenciesModel(
|
||||
response = createQuotesResponse(ethQuote),
|
||||
filteredIds = emptySet(),
|
||||
expected = createQuotesResponse(ethQuote),
|
||||
),
|
||||
GetResponseWithUnsupportedCurrenciesModel(
|
||||
response = createQuotesResponse(
|
||||
"arbitrum-one" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
|
||||
ethQuote,
|
||||
),
|
||||
filteredIds = blockchainCoinIds,
|
||||
expected = createQuotesResponse(
|
||||
*l2BlockchainsCoinIds.map { it to zeroQuote }.toTypedArray(),
|
||||
"arbitrum-one" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
|
||||
ethQuote,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun createQuotesResponse(vararg quotes: Pair<String, Quote>): QuotesResponse {
|
||||
return QuotesResponse(quotes = quotes.toMap())
|
||||
}
|
||||
}
|
||||
|
||||
data class GetResponseWithUnsupportedCurrenciesModel(
|
||||
val response: QuotesResponse,
|
||||
val filteredIds: Set<String>,
|
||||
val expected: QuotesResponse,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
val blockchainCoinIds = Blockchain.entries
|
||||
.filterNot(Blockchain::isTestnet)
|
||||
.mapTo(destination = hashSetOf(), transform = Blockchain::toCoinId)
|
||||
|
||||
val notL2BlockchainsCoinIds = Blockchain.entries
|
||||
.filterNot { it.isTestnet() || it.isL2EthereumNetwork() }
|
||||
.mapTo(hashSetOf()) { it.toCoinId() }
|
||||
}
|
||||
}
|
||||
|
|
@ -502,7 +502,7 @@ internal class DefaultStakingRepository(
|
|||
userWalletId: UserWalletId,
|
||||
cryptoCurrency: CryptoCurrency,
|
||||
): YieldBalance {
|
||||
val stakingId = stakingIdFactory.createForDefault(
|
||||
val stakingId = stakingIdFactory.create(
|
||||
userWalletId = userWalletId,
|
||||
currencyId = cryptoCurrency.id,
|
||||
network = cryptoCurrency.network,
|
||||
|
|
@ -639,7 +639,7 @@ internal class DefaultStakingRepository(
|
|||
userWalletId: UserWalletId,
|
||||
cryptoCurrencies: List<CryptoCurrency>,
|
||||
): YieldBalanceList {
|
||||
val stakingIds = cryptoCurrencies.flatMap {
|
||||
val stakingIds = cryptoCurrencies.mapNotNull {
|
||||
stakingIdFactory.create(userWalletId = userWalletId, currencyId = it.id, network = it.network)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ internal class DefaultMultiYieldBalanceFetcher @Inject constructor(
|
|||
private suspend fun getStakingIds(params: MultiYieldBalanceFetcher.Params) = either {
|
||||
val stakingIds = catch(
|
||||
block = {
|
||||
params.currencyIdWithNetworkMap.flatMapTo(hashSetOf()) { (currencyId, network) ->
|
||||
params.currencyIdWithNetworkMap.mapNotNullTo(hashSetOf()) { (currencyId, network) ->
|
||||
stakingIdFactory.create(
|
||||
userWalletId = params.userWalletId,
|
||||
currencyId = currencyId,
|
||||
|
|
|
|||
|
|
@ -39,26 +39,24 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private var stakingIds: Set<StakingID>? = null
|
||||
private var stakingId: StakingID? = null
|
||||
|
||||
override fun produce(): Flow<YieldBalance> {
|
||||
return multiYieldBalanceSupplier(
|
||||
params = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId),
|
||||
)
|
||||
.mapNotNull { balances ->
|
||||
val currentStakingIds = getStakingIds().ifEmpty {
|
||||
return@mapNotNull YieldBalance.Unsupported
|
||||
}
|
||||
val currentStakingId = getStakingId() ?: return@mapNotNull YieldBalance.Unsupported
|
||||
|
||||
balances.firstOrNull { currentStakingIds.contains(it.getStakingId()) }
|
||||
balances.firstOrNull { it.getStakingId() == currentStakingId }
|
||||
?: YieldBalance.Unsupported
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
private suspend fun getStakingIds(): Set<StakingID> {
|
||||
val saved = stakingIds
|
||||
private suspend fun getStakingId(): StakingID? {
|
||||
val saved = stakingId
|
||||
|
||||
if (saved != null) return saved
|
||||
|
||||
|
|
@ -67,7 +65,7 @@ internal class DefaultSingleYieldBalanceProducer @AssistedInject constructor(
|
|||
currencyId = params.currencyId,
|
||||
network = params.network,
|
||||
)
|
||||
.also { stakingIds = it }
|
||||
.also { stakingId = it }
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
|
|
|
|||
|
|
@ -21,21 +21,7 @@ internal class StakingIdFactory @Inject constructor(
|
|||
private val walletManagersFacade: WalletManagersFacade,
|
||||
) {
|
||||
|
||||
suspend fun create(userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, network: Network): Set<StakingID> {
|
||||
val addresses = walletManagersFacade.getAddresses(userWalletId = userWalletId, network = network)
|
||||
|
||||
val integrationId = createIntegrationId(currencyId) ?: return emptySet()
|
||||
|
||||
return addresses.mapTo(hashSetOf()) { address ->
|
||||
StakingID(integrationId = integrationId, address = address.value)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createForDefault(
|
||||
userWalletId: UserWalletId,
|
||||
currencyId: CryptoCurrency.ID,
|
||||
network: Network,
|
||||
): StakingID? {
|
||||
suspend fun create(userWalletId: UserWalletId, currencyId: CryptoCurrency.ID, network: Network): StakingID? {
|
||||
val address = walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
|
||||
val integrationId = createIntegrationId(currencyId)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
|
||||
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
|
||||
|
||||
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
|
||||
|
|
@ -102,8 +102,8 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
|
||||
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
|
||||
|
||||
val yields = listOf(MockYieldDTOFactory.create(tonId))
|
||||
|
|
@ -152,7 +152,7 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
|
||||
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
|
||||
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
|
||||
|
|
@ -183,7 +183,7 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
coVerify { userWalletsStore.getSyncOrNull(params.userWalletId) }
|
||||
|
||||
coVerify(inverse = true) {
|
||||
stakingIdFactory.createForDefault(params.userWalletId, ton.id, ton.network)
|
||||
stakingIdFactory.create(params.userWalletId, ton.id, ton.network)
|
||||
yieldsBalancesStore.refresh(userWalletId = any(), stakingIds = any())
|
||||
stakingYieldsStore.getSyncWithTimeout()
|
||||
stakeKitApi.getSingleYieldBalance(integrationId = any(), body = any())
|
||||
|
|
@ -199,15 +199,15 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `fetch yields balances failure if stakingIdFactory returns empty list`() = runTest {
|
||||
fun `fetch yields balances failure if stakingIdFactory returns null`() = runTest {
|
||||
// Arrange
|
||||
val currencyIdWithNetworkMap = mapOf(ton.id to ton.network, solana.id to solana.network)
|
||||
|
||||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns emptySet()
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns emptySet()
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns null
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns null
|
||||
|
||||
// Actual
|
||||
val actual = fetcher.invoke(params)
|
||||
|
|
@ -242,8 +242,8 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
|
||||
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
|
||||
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns null
|
||||
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
|
||||
|
|
@ -281,8 +281,8 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
|
||||
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
|
||||
coEvery { stakingYieldsStore.getSyncWithTimeout() } returns emptyList()
|
||||
coEvery { yieldsBalancesStore.storeError(userWalletId, tonAndSolanaIds) } just Runs
|
||||
|
|
@ -320,8 +320,8 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
|
||||
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
|
||||
|
||||
val yields = listOf(
|
||||
|
|
@ -364,8 +364,8 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
|
||||
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
|
||||
|
||||
val yields = listOf(MockYieldDTOFactory.create(StakingID(integrationId = "polygon", address = "0x1")))
|
||||
|
|
@ -411,8 +411,8 @@ internal class DefaultMultiYieldBalanceFetcherTest {
|
|||
val params = MultiYieldBalanceFetcher.Params(userWalletId, currencyIdWithNetworkMap)
|
||||
|
||||
coEvery { userWalletsStore.getSyncOrNull(params.userWalletId) } returns userWallet
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns setOf(tonId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns setOf(solanaId)
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, ton.id, ton.network) } returns tonId
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, solana.id, solana.network) } returns solanaId
|
||||
coEvery { yieldsBalancesStore.refresh(params.userWalletId, tonAndSolanaIds) } just Runs
|
||||
|
||||
val yields = listOf(MockYieldDTOFactory.create(tonId), MockYieldDTOFactory.create(solanaId))
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
|
|||
|
||||
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
|
||||
every { multiNetworkStatusSupplier(multiParams) } returns expected
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
|
|||
|
||||
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
|
||||
every { multiNetworkStatusSupplier(multiParams) } returns expected
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
|
|||
|
||||
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
|
||||
every { multiNetworkStatusSupplier(multiParams) } returns expected
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
|
||||
|
||||
val actual = producer.produceWithFallback()
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
|
|||
val fallbackStatus = YieldBalance.Error(integrationId = tonId.integrationId, address = null)
|
||||
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
|
||||
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
|
||||
|
||||
innerFlow.emit(value = true)
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ internal class DefaultSingleYieldBalanceProducerTest {
|
|||
|
||||
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
|
||||
every { multiNetworkStatusSupplier(multiParams) } returns yieldBalancesFlow
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns stakingIds
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns tonId
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
|
|
@ -203,14 +203,14 @@ internal class DefaultSingleYieldBalanceProducerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `test if wallet manager facade returns empty set`() = runTest {
|
||||
fun `test if wallet manager facade returns null`() = runTest {
|
||||
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
|
||||
|
||||
val yieldBalancesFlow = flowOf(setOf(balance))
|
||||
|
||||
val multiParams = MultiYieldBalanceProducer.Params(userWalletId = params.userWalletId)
|
||||
every { multiNetworkStatusSupplier(multiParams) } returns yieldBalancesFlow
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns emptySet()
|
||||
coEvery { stakingIdFactory.create(params.userWalletId, params.currencyId, params.network) } returns null
|
||||
|
||||
val actual = producer.produce()
|
||||
|
||||
|
|
@ -235,7 +235,5 @@ internal class DefaultSingleYieldBalanceProducerTest {
|
|||
integrationId = "solana-sol-native-multivalidator-staking",
|
||||
address = "0x1",
|
||||
)
|
||||
|
||||
val stakingIds = setOf(tonId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
package com.tangem.data.staking.utils
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchainsdk.utils.toCoinId
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.common.test.utils.ProvideTestModels
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.staking.model.StakingID
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class StakingIdFactoryTest {
|
||||
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk()
|
||||
private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(walletManagersFacade)
|
||||
}
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class CreateIntegrationId {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun createIntegrationId(model: CreateIntegrationIdModel) {
|
||||
// Act
|
||||
val actual = factory.createIntegrationId(currencyId = model.currencyId)
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(actual).isEqualTo(model.expected)
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.TON),
|
||||
expected = "ton-ton-chorus-one-pools-staking",
|
||||
),
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Solana),
|
||||
expected = "solana-sol-native-multivalidator-staking",
|
||||
),
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Cosmos),
|
||||
expected = "cosmos-atom-native-staking",
|
||||
),
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Tron),
|
||||
expected = "tron-trx-native-staking",
|
||||
),
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = CryptoCurrency.ID.fromValue(value = "coin⟨ETH⟩polygon-ecosystem-token⚓"),
|
||||
expected = "ethereum-matic-native-staking",
|
||||
),
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.BSC),
|
||||
expected = "bsc-bnb-native-staking",
|
||||
),
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Cardano),
|
||||
expected = "cardano-ada-native-staking",
|
||||
),
|
||||
CreateIntegrationIdModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Bitcoin),
|
||||
expected = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class CreateIntegrationIdModel(val currencyId: CryptoCurrency.ID, val expected: String?)
|
||||
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Create {
|
||||
|
||||
private val defaultAddress = "address"
|
||||
|
||||
@Test
|
||||
fun `create returns null if address is null`() = runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId(stringValue = "011")
|
||||
val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON)
|
||||
|
||||
coEvery {
|
||||
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = currency.network)
|
||||
} returns null
|
||||
|
||||
// Act
|
||||
val actual = factory.create(
|
||||
userWalletId = userWalletId,
|
||||
currencyId = currency.id,
|
||||
network = currency.network,
|
||||
)
|
||||
|
||||
// Assert
|
||||
val expected = null
|
||||
Truth.assertThat(actual).isEqualTo(expected)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = currency.network)
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun create(model: CreateModel) = runTest {
|
||||
// Arrange
|
||||
val userWalletId = UserWalletId(stringValue = "011")
|
||||
val network = MockCryptoCurrencyFactory().createCoin(Blockchain.TON).network
|
||||
|
||||
coEvery {
|
||||
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
|
||||
} returns defaultAddress
|
||||
|
||||
// Act
|
||||
val actual = factory.create(userWalletId = userWalletId, currencyId = model.currencyId, network = network)
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(actual).isEqualTo(model.expected)
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
walletManagersFacade.getDefaultAddress(userWalletId = userWalletId, network = network)
|
||||
}
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
CreateModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.TON),
|
||||
expected = createStakingId(integrationId = "ton-ton-chorus-one-pools-staking"),
|
||||
),
|
||||
CreateModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Solana),
|
||||
expected = createStakingId(integrationId = "solana-sol-native-multivalidator-staking"),
|
||||
),
|
||||
CreateModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Cosmos),
|
||||
expected = createStakingId(integrationId = "cosmos-atom-native-staking"),
|
||||
),
|
||||
CreateModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Tron),
|
||||
expected = createStakingId(integrationId = "tron-trx-native-staking"),
|
||||
),
|
||||
CreateModel(
|
||||
currencyId = CryptoCurrency.ID.fromValue(value = "coin⟨ETH⟩polygon-ecosystem-token⚓"),
|
||||
expected = createStakingId(integrationId = "ethereum-matic-native-staking"),
|
||||
),
|
||||
CreateModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.BSC),
|
||||
expected = createStakingId(integrationId = "bsc-bnb-native-staking"),
|
||||
),
|
||||
CreateModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Cardano),
|
||||
expected = createStakingId(integrationId = "cardano-ada-native-staking"),
|
||||
),
|
||||
CreateModel(
|
||||
currencyId = createCurrencyId(blockchain = Blockchain.Bitcoin),
|
||||
expected = null,
|
||||
),
|
||||
)
|
||||
|
||||
private fun createStakingId(integrationId: String): StakingID {
|
||||
return StakingID(integrationId = integrationId, address = defaultAddress)
|
||||
}
|
||||
}
|
||||
|
||||
data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: StakingID?)
|
||||
|
||||
private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID {
|
||||
return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}⟩${blockchain.toCoinId()}⚓")
|
||||
}
|
||||
}
|
||||
1
data/swap/.gitignore
vendored
Normal file
1
data/swap/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
54
data/swap/build.gradle.kts
Normal file
54
data/swap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
plugins {
|
||||
alias(deps.plugins.android.library)
|
||||
alias(deps.plugins.kotlin.android)
|
||||
alias(deps.plugins.kotlin.kapt)
|
||||
alias(deps.plugins.hilt.android)
|
||||
alias(deps.plugins.ksp)
|
||||
id("configuration")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tangem.data.swap"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Core */
|
||||
implementation(projects.core.datasource)
|
||||
implementation(projects.core.utils)
|
||||
|
||||
/** Data */
|
||||
implementation(projects.data.common)
|
||||
implementation(projects.data.express)
|
||||
|
||||
/** Domain */
|
||||
implementation(projects.domain.express.models)
|
||||
implementation(projects.domain.express)
|
||||
implementation(projects.domain.swap.models)
|
||||
implementation(projects.domain.swap)
|
||||
implementation(projects.domain.wallets.models)
|
||||
implementation(projects.domain.wallets)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.tokens)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.models)
|
||||
|
||||
/** Tangem SDK */
|
||||
implementation(tangemDeps.blockchain) {
|
||||
exclude(module = "joda-time")
|
||||
}
|
||||
|
||||
/** Libs */
|
||||
implementation(projects.libs.blockchainSdk)
|
||||
|
||||
/** Other */
|
||||
implementation(deps.androidx.datastore)
|
||||
implementation(deps.jodatime)
|
||||
implementation(deps.kotlin.coroutines)
|
||||
implementation(deps.moshi)
|
||||
implementation(deps.moshi.kotlin)
|
||||
implementation(deps.timber)
|
||||
|
||||
/** DI */
|
||||
implementation(deps.hilt.android)
|
||||
kapt(deps.hilt.kapt)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.tangem.data.swap
|
||||
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.domain.express.models.ExpressError
|
||||
import com.tangem.domain.swap.SwapErrorResolver
|
||||
|
||||
internal class DefaultSwapErrorResolver(
|
||||
private val expressErrorConverter: ExpressErrorConverter,
|
||||
) : SwapErrorResolver {
|
||||
override fun resolve(throwable: Throwable): ExpressError {
|
||||
return when (throwable) {
|
||||
is ApiResponseError.HttpException -> {
|
||||
expressErrorConverter.convert(throwable.errorBody.orEmpty())
|
||||
}
|
||||
else -> ExpressError.UnknownError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
package com.tangem.data.swap
|
||||
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.swap.converter.TokenInfoConverter
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.request.PairsRequestBody
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressUtils
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.express.models.ExpressRateType
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.domain.swap.models.SwapPairModel
|
||||
import com.tangem.domain.swap.models.SwapQuoteModel
|
||||
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
|
||||
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.math.BigDecimal
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultSwapRepositoryV2 @Inject constructor(
|
||||
private val tangemExpressApi: TangemExpressApi,
|
||||
private val expressRepository: ExpressRepository,
|
||||
private val coroutineDispatcher: CoroutineDispatcherProvider,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val currencyStatusOperations: BaseCurrencyStatusOperations,
|
||||
) : SwapRepositoryV2 {
|
||||
|
||||
private val tokenInfoConverter = TokenInfoConverter()
|
||||
|
||||
override suspend fun getPairs(
|
||||
userWallet: UserWallet,
|
||||
initialCurrency: CryptoCurrency,
|
||||
cryptoCurrencyStatusList: List<CryptoCurrencyStatus>,
|
||||
): List<SwapPairModel> = withContext(coroutineDispatcher.io) {
|
||||
val cryptoCurrencyList = cryptoCurrencyStatusList.map { it.currency }
|
||||
|
||||
val allPairs = getPairsInternal(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
)
|
||||
|
||||
val providers = expressRepository.getProviders(userWallet = userWallet)
|
||||
val mappedProviders = providers.associateBy(ExpressProvider::providerId)
|
||||
|
||||
allPairs.map { pair ->
|
||||
async {
|
||||
val statusFrom = cryptoCurrencyStatusList
|
||||
.firstOrNull {
|
||||
it.currency.getContractAddress() == pair.from.contractAddress &&
|
||||
it.currency.network.backendId == pair.from.network
|
||||
}
|
||||
val statusTo = cryptoCurrencyStatusList
|
||||
.firstOrNull {
|
||||
it.currency.getContractAddress() == pair.to.contractAddress &&
|
||||
it.currency.network.backendId == pair.to.network
|
||||
}
|
||||
|
||||
if (statusFrom != null && statusTo != null) {
|
||||
SwapPairModel(
|
||||
from = statusFrom,
|
||||
to = statusTo,
|
||||
providers = pair.providers.mapNotNull {
|
||||
mappedProviders[it.providerId]
|
||||
},
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
override suspend fun getPairsOnly(
|
||||
userWallet: UserWallet,
|
||||
initialCurrency: CryptoCurrency,
|
||||
cryptoCurrencyList: List<CryptoCurrency>,
|
||||
): List<SwapPairModel> = withContext(coroutineDispatcher.io) {
|
||||
val allPairs = getPairsInternal(
|
||||
userWallet = userWallet,
|
||||
initialCurrency = initialCurrency,
|
||||
cryptoCurrencyList = cryptoCurrencyList,
|
||||
)
|
||||
|
||||
allPairs.map { pair ->
|
||||
async {
|
||||
val statusFromDeferred = async {
|
||||
cryptoCurrencyList
|
||||
.firstOrNull {
|
||||
it.getContractAddress() == pair.from.contractAddress &&
|
||||
it.network.backendId == pair.from.network
|
||||
}
|
||||
}
|
||||
val statusToDeferred = async {
|
||||
cryptoCurrencyList
|
||||
.firstOrNull {
|
||||
it.getContractAddress() == pair.to.contractAddress &&
|
||||
it.network.backendId == pair.to.network
|
||||
}
|
||||
}
|
||||
|
||||
createPairModelOnly(
|
||||
currencyFrom = statusFromDeferred.await(),
|
||||
currencyTo = statusToDeferred.await(),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
override suspend fun getSwapQuote(
|
||||
userWallet: UserWallet,
|
||||
fromCryptoCurrency: CryptoCurrency,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
fromAmount: BigDecimal,
|
||||
provider: ExpressProvider,
|
||||
rateType: ExpressRateType,
|
||||
): SwapQuoteModel = withContext(coroutineDispatcher.io) {
|
||||
val response = tangemExpressApi.getExchangeQuote(
|
||||
fromAmount = fromAmount.movePointRight(fromCryptoCurrency.decimals).toString(),
|
||||
fromNetwork = fromCryptoCurrency.network.backendId,
|
||||
fromContractAddress = fromCryptoCurrency.getContractAddress(),
|
||||
fromDecimals = fromCryptoCurrency.decimals,
|
||||
toNetwork = toCryptoCurrency.network.backendId,
|
||||
toContractAddress = toCryptoCurrency.getContractAddress(),
|
||||
toDecimals = toCryptoCurrency.decimals,
|
||||
providerId = provider.providerId,
|
||||
rateType = rateType.name.lowercase(),
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
),
|
||||
).getOrThrow()
|
||||
|
||||
val toTokenAmount = requireNotNull(response.toAmount.toBigDecimalOrNull()?.movePointLeft(response.toDecimals))
|
||||
|
||||
return@withContext SwapQuoteModel(
|
||||
provider = provider,
|
||||
toTokenAmount = toTokenAmount,
|
||||
allowanceContract = response.allowanceContract,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun CoroutineScope.getPairsInternal(
|
||||
userWallet: UserWallet,
|
||||
initialCurrency: CryptoCurrency,
|
||||
cryptoCurrencyList: List<CryptoCurrency>,
|
||||
) = awaitAll(
|
||||
// original pairs
|
||||
async {
|
||||
invokePairRequest(
|
||||
userWallet = userWallet,
|
||||
from = arrayListOf(initialCurrency),
|
||||
to = cryptoCurrencyList,
|
||||
)
|
||||
},
|
||||
// reversed pairs
|
||||
async {
|
||||
invokePairRequest(
|
||||
userWallet = userWallet,
|
||||
from = cryptoCurrencyList,
|
||||
to = arrayListOf(initialCurrency),
|
||||
)
|
||||
},
|
||||
).flatten()
|
||||
|
||||
private suspend fun invokePairRequest(
|
||||
userWallet: UserWallet,
|
||||
from: List<CryptoCurrency>,
|
||||
to: List<CryptoCurrency>,
|
||||
) = safeApiCall(
|
||||
call = {
|
||||
tangemExpressApi.getPairs(
|
||||
userWalletId = userWallet.walletId.stringValue,
|
||||
refCode = ExpressUtils.getRefCode(
|
||||
userWallet = userWallet,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
),
|
||||
body = PairsRequestBody(
|
||||
from = tokenInfoConverter.convertList(from),
|
||||
to = tokenInfoConverter.convertList(to),
|
||||
),
|
||||
).getOrThrow()
|
||||
},
|
||||
onError = {
|
||||
Timber.w(it, "Unable to get pairs")
|
||||
throw it
|
||||
},
|
||||
)
|
||||
|
||||
private suspend fun CoroutineScope.createPairModelOnly(
|
||||
currencyFrom: CryptoCurrency?,
|
||||
currencyTo: CryptoCurrency?,
|
||||
userWalletId: UserWalletId,
|
||||
): SwapPairModel? {
|
||||
return if (currencyFrom != null && currencyTo != null) {
|
||||
val statusFrom = currencyStatusOperations.getCurrencyStatusSync(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyId = currencyFrom.id,
|
||||
).getOrNull()
|
||||
val statusTo = currencyStatusOperations.getCurrencyStatusSync(
|
||||
userWalletId = userWalletId,
|
||||
cryptoCurrencyId = currencyTo.id,
|
||||
).getOrNull()
|
||||
|
||||
if (statusFrom != null && statusTo != null) {
|
||||
SwapPairModel(
|
||||
from = statusFrom,
|
||||
to = statusTo,
|
||||
providers = emptyList(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun CryptoCurrency.getContractAddress(): String {
|
||||
return when (this) {
|
||||
is CryptoCurrency.Token -> this.contractAddress
|
||||
is CryptoCurrency.Coin -> "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
package com.tangem.data.swap
|
||||
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.common.currency.UserTokensResponseFactory
|
||||
import com.tangem.data.swap.converter.transaction.SavedSwapStatusConverter
|
||||
import com.tangem.data.swap.converter.transaction.SavedSwapTransactionConverter
|
||||
import com.tangem.data.swap.converter.transaction.SavedSwapTransactionListConverter
|
||||
import com.tangem.data.swap.models.LastSwappedCryptoCurrencyDTO
|
||||
import com.tangem.data.swap.models.SwapStatusDTO
|
||||
import com.tangem.data.swap.models.SwapTransactionDTO
|
||||
import com.tangem.data.swap.models.SwapTransactionListDTO
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectList
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectListSync
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.SwapTransactionRepository
|
||||
import com.tangem.domain.swap.models.SwapStatusModel
|
||||
import com.tangem.domain.swap.models.SwapTransactionListModel
|
||||
import com.tangem.domain.swap.models.SwapTransactionModel
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.requireColdWallet
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.extensions.addOrReplace
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class DefaultSwapTransactionRepository(
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : SwapTransactionRepository {
|
||||
|
||||
private val listConverter by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SavedSwapTransactionListConverter(responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory)
|
||||
}
|
||||
private val converter by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SavedSwapTransactionConverter(responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory)
|
||||
}
|
||||
private val savedStatusConverter by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SavedSwapStatusConverter()
|
||||
}
|
||||
private val userTokensResponseFactory = UserTokensResponseFactory()
|
||||
|
||||
override suspend fun storeTransaction(
|
||||
userWalletId: UserWalletId,
|
||||
fromCryptoCurrency: CryptoCurrency,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
transaction: SwapTransactionModel,
|
||||
) {
|
||||
transaction.status?.let {
|
||||
storeTransactionState(
|
||||
txId = transaction.txId,
|
||||
status = it,
|
||||
refundTokenCurrency = null,
|
||||
)
|
||||
}
|
||||
appPreferencesStore.editData { mutablePreferences ->
|
||||
val savedTransactions: List<SwapTransactionListDTO>? = mutablePreferences.getObjectList(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
|
||||
)
|
||||
val tokenTransactions = savedTransactions
|
||||
?.firstOrNull {
|
||||
it.checkId(
|
||||
checkUserWalletId = userWalletId,
|
||||
fromCurrencyId = fromCryptoCurrency.id,
|
||||
toCurrencyId = toCryptoCurrency.id,
|
||||
)
|
||||
}
|
||||
?.transactions
|
||||
?.addOrReplace(
|
||||
item = converter.convert(transaction),
|
||||
predicate = { it.txId == transaction.txId },
|
||||
) ?: listOf(converter.convert(transaction))
|
||||
|
||||
mutablePreferences.setObject(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
|
||||
value = savedTransactions?.updateList(
|
||||
userWalletId = userWalletId,
|
||||
fromCryptoCurrency = fromCryptoCurrency,
|
||||
toCryptoCurrency = toCryptoCurrency,
|
||||
transactions = tokenTransactions,
|
||||
) ?: listOf(
|
||||
listConverter.default(
|
||||
userWalletId = userWalletId,
|
||||
fromCryptoCurrency = fromCryptoCurrency,
|
||||
toCryptoCurrency = toCryptoCurrency,
|
||||
tokenTransactions = listOf(converter.convert(transaction)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTransactions(
|
||||
userWallet: UserWallet,
|
||||
cryptoCurrencyId: CryptoCurrency.ID,
|
||||
): Flow<List<SwapTransactionListModel>?> {
|
||||
return withContext(dispatchers.io) {
|
||||
val txStatuses = appPreferencesStore.getObjectMapSync<SwapStatusDTO>(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
|
||||
)
|
||||
appPreferencesStore.getObjectList<SwapTransactionListDTO>(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
|
||||
).map { savedTransactions ->
|
||||
val currencyTxs = savedTransactions
|
||||
?.filter {
|
||||
it.userWalletId == userWallet.walletId.stringValue &&
|
||||
(
|
||||
it.toCryptoCurrencyId == cryptoCurrencyId.value ||
|
||||
it.fromCryptoCurrencyId == cryptoCurrencyId.value
|
||||
)
|
||||
}
|
||||
|
||||
currencyTxs?.mapNotNull {
|
||||
listConverter.convertBack(
|
||||
value = it,
|
||||
scanResponse = userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
|
||||
txStatuses = txStatuses,
|
||||
)
|
||||
}
|
||||
}.flowOn(dispatchers.io)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeTransaction(
|
||||
userWalletId: UserWalletId,
|
||||
fromCryptoCurrency: CryptoCurrency,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
txId: String,
|
||||
) {
|
||||
clearTransactionsStatuses(txId = txId)
|
||||
appPreferencesStore.editData { mutablePreferences ->
|
||||
val savedList: List<SwapTransactionListDTO>? = mutablePreferences.getObjectList(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
|
||||
)
|
||||
val tokenTransactions = savedList
|
||||
?.firstOrNull {
|
||||
it.checkId(
|
||||
checkUserWalletId = userWalletId,
|
||||
fromCurrencyId = fromCryptoCurrency.id,
|
||||
toCurrencyId = toCryptoCurrency.id,
|
||||
)
|
||||
}
|
||||
?.transactions
|
||||
?.filterNot { it.txId == txId }
|
||||
|
||||
val editedList =
|
||||
if (tokenTransactions.isNullOrEmpty()) {
|
||||
savedList?.filterNot {
|
||||
it.checkId(
|
||||
checkUserWalletId = userWalletId,
|
||||
fromCurrencyId = fromCryptoCurrency.id,
|
||||
toCurrencyId = toCryptoCurrency.id,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
savedList.updateList(
|
||||
userWalletId = userWalletId,
|
||||
fromCryptoCurrency = fromCryptoCurrency,
|
||||
toCryptoCurrency = toCryptoCurrency,
|
||||
transactions = tokenTransactions,
|
||||
)
|
||||
}
|
||||
|
||||
if (editedList.isNullOrEmpty()) {
|
||||
mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_KEY)
|
||||
} else {
|
||||
mutablePreferences.setObject(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_KEY,
|
||||
value = editedList,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun storeTransactionState(
|
||||
txId: String,
|
||||
status: SwapStatusModel,
|
||||
refundTokenCurrency: CryptoCurrency?,
|
||||
) {
|
||||
appPreferencesStore.editData { mutablePreferences ->
|
||||
val savedMap = mutablePreferences.getObjectMap<SwapStatusDTO>(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
|
||||
)
|
||||
|
||||
val updatesMap = savedMap.toMutableMap()
|
||||
updatesMap[txId] = savedStatusConverter.convertBack(
|
||||
status.copy(
|
||||
refundTokensResponse = refundTokenCurrency?.let {
|
||||
userTokensResponseFactory.createResponseToken(refundTokenCurrency)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
mutablePreferences.setObjectMap(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
|
||||
value = updatesMap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getLastSwappedCryptoCurrencyId(userWalletId: UserWalletId): String? {
|
||||
val lastSwappedCurrencies = appPreferencesStore.getObjectListSync<LastSwappedCryptoCurrencyDTO>(
|
||||
key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY,
|
||||
)
|
||||
|
||||
return lastSwappedCurrencies.find { userWalletId.stringValue == it.userWalletId }?.cryptoCurrencyId
|
||||
}
|
||||
|
||||
override suspend fun storeLastSwappedCryptoCurrencyId(
|
||||
userWalletId: UserWalletId,
|
||||
cryptoCurrencyId: CryptoCurrency.ID,
|
||||
) {
|
||||
appPreferencesStore.editData { mutablePreferences ->
|
||||
val lastSwappedCryptoCurrencies: List<LastSwappedCryptoCurrencyDTO>? = mutablePreferences.getObjectList(
|
||||
key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY,
|
||||
)
|
||||
|
||||
val newList = if (lastSwappedCryptoCurrencies != null) {
|
||||
lastSwappedCryptoCurrencies.filter {
|
||||
it.userWalletId != userWalletId.stringValue
|
||||
} + LastSwappedCryptoCurrencyDTO(userWalletId.stringValue, cryptoCurrencyId.value)
|
||||
} else {
|
||||
listOf(LastSwappedCryptoCurrencyDTO(userWalletId.stringValue, cryptoCurrencyId.value))
|
||||
}
|
||||
|
||||
mutablePreferences.setObjectList(
|
||||
key = PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY,
|
||||
value = newList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SwapTransactionListDTO.checkId(
|
||||
checkUserWalletId: UserWalletId,
|
||||
fromCurrencyId: CryptoCurrency.ID,
|
||||
toCurrencyId: CryptoCurrency.ID,
|
||||
): Boolean {
|
||||
return userWalletId == checkUserWalletId.stringValue &&
|
||||
toCryptoCurrencyId == toCurrencyId.value &&
|
||||
fromCryptoCurrencyId == fromCurrencyId.value
|
||||
}
|
||||
|
||||
private fun List<SwapTransactionListDTO>.updateList(
|
||||
userWalletId: UserWalletId,
|
||||
fromCryptoCurrency: CryptoCurrency,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
transactions: List<SwapTransactionDTO>,
|
||||
): List<SwapTransactionListDTO> {
|
||||
return addOrReplace(
|
||||
item = listConverter.default(
|
||||
userWalletId = userWalletId,
|
||||
fromCryptoCurrency = fromCryptoCurrency,
|
||||
toCryptoCurrency = toCryptoCurrency,
|
||||
tokenTransactions = transactions,
|
||||
),
|
||||
predicate = {
|
||||
it.checkId(
|
||||
checkUserWalletId = userWalletId,
|
||||
fromCurrencyId = fromCryptoCurrency.id,
|
||||
toCurrencyId = toCryptoCurrency.id,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun clearTransactionsStatuses(txId: String) {
|
||||
appPreferencesStore.editData { mutablePreferences ->
|
||||
val savedList = mutablePreferences.getObjectMap<SwapStatusModel>(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
|
||||
)
|
||||
val editedList = savedList.filterNot { it.key == txId }
|
||||
|
||||
if (editedList.isEmpty()) {
|
||||
mutablePreferences.remove(key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY)
|
||||
} else {
|
||||
mutablePreferences.setObjectMap(
|
||||
key = PreferencesKeys.SWAP_TRANSACTIONS_STATUSES_KEY,
|
||||
value = editedList,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.data.swap.converter
|
||||
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeStatusResponse
|
||||
import com.tangem.domain.swap.models.SwapStatus
|
||||
import com.tangem.domain.swap.models.SwapStatusModel
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class SwapStatusConverter : Converter<ExchangeStatusResponse, SwapStatusModel> {
|
||||
override fun convert(value: ExchangeStatusResponse): SwapStatusModel {
|
||||
return SwapStatusModel(
|
||||
providerId = value.providerId,
|
||||
status = SwapStatus.entries.firstOrNull {
|
||||
it.name.lowercase() == value.status.name.lowercase()
|
||||
},
|
||||
txId = value.externalTxId,
|
||||
txExternalUrl = value.externalTxUrl,
|
||||
txExternalId = value.externalTxId,
|
||||
refundNetwork = value.refundNetwork,
|
||||
refundContractAddress = value.refundContractAddress,
|
||||
createdAt = value.createdAt,
|
||||
averageDuration = value.averageDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.tangem.data.swap.converter
|
||||
|
||||
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.swap.models.TokenInfo
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
class TokenInfoConverter : Converter<CryptoCurrency, LeastTokenInfo> {
|
||||
|
||||
override fun convert(value: CryptoCurrency): LeastTokenInfo {
|
||||
return LeastTokenInfo(
|
||||
contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0",
|
||||
network = value.network.backendId,
|
||||
)
|
||||
}
|
||||
|
||||
fun convert(value: TokenInfo): LeastTokenInfo {
|
||||
return LeastTokenInfo(
|
||||
contractAddress = value.contractAddress,
|
||||
network = value.network,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.tangem.data.swap.converter.transaction
|
||||
|
||||
import com.tangem.data.swap.models.SavedSwapStatus
|
||||
import com.tangem.data.swap.models.SwapStatusDTO
|
||||
import com.tangem.domain.swap.models.SwapStatus
|
||||
import com.tangem.domain.swap.models.SwapStatusModel
|
||||
import com.tangem.utils.converter.TwoWayConverter
|
||||
|
||||
internal class SavedSwapStatusConverter : TwoWayConverter<SwapStatusDTO, SwapStatusModel> {
|
||||
|
||||
override fun convert(value: SwapStatusDTO) = SwapStatusModel(
|
||||
providerId = value.providerId,
|
||||
status = SwapStatus.entries.firstOrNull {
|
||||
it.name.lowercase() == value.status?.name?.lowercase()
|
||||
},
|
||||
txId = value.txExternalId,
|
||||
txExternalUrl = value.txExternalUrl,
|
||||
txExternalId = value.txExternalId,
|
||||
refundNetwork = value.refundNetwork,
|
||||
refundContractAddress = value.refundContractAddress,
|
||||
createdAt = value.createdAt,
|
||||
averageDuration = value.averageDuration,
|
||||
)
|
||||
|
||||
override fun convertBack(value: SwapStatusModel) = SwapStatusDTO(
|
||||
providerId = value.providerId,
|
||||
status = SavedSwapStatus.entries.firstOrNull {
|
||||
it.name.lowercase() == value.status?.name?.lowercase()
|
||||
},
|
||||
txId = value.txExternalId,
|
||||
txExternalUrl = value.txExternalUrl,
|
||||
txExternalId = value.txExternalId,
|
||||
refundNetwork = value.refundNetwork,
|
||||
refundContractAddress = value.refundContractAddress,
|
||||
createdAt = value.createdAt,
|
||||
averageDuration = value.averageDuration,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.tangem.data.swap.converter.transaction
|
||||
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.swap.models.SwapStatusDTO
|
||||
import com.tangem.data.swap.models.SwapTransactionDTO
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.swap.models.SwapTransactionModel
|
||||
import com.tangem.utils.converter.TwoWayConverter
|
||||
|
||||
internal class SavedSwapTransactionConverter(
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : TwoWayConverter<SwapTransactionModel, SwapTransactionDTO> {
|
||||
|
||||
private val statusConverter by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SavedSwapStatusConverter()
|
||||
}
|
||||
|
||||
override fun convert(value: SwapTransactionModel) = SwapTransactionDTO(
|
||||
txId = value.txId,
|
||||
timestamp = value.timestamp,
|
||||
fromCryptoAmount = value.fromCryptoAmount,
|
||||
toCryptoAmount = value.toCryptoAmount,
|
||||
provider = value.provider,
|
||||
status = value.status,
|
||||
)
|
||||
|
||||
override fun convertBack(value: SwapTransactionDTO) = SwapTransactionModel(
|
||||
txId = value.txId,
|
||||
timestamp = value.timestamp,
|
||||
fromCryptoAmount = value.fromCryptoAmount,
|
||||
toCryptoAmount = value.toCryptoAmount,
|
||||
provider = value.provider,
|
||||
status = value.status,
|
||||
)
|
||||
|
||||
fun convertBack(
|
||||
value: SwapTransactionDTO,
|
||||
scanResponse: ScanResponse,
|
||||
txStatuses: Map<String, SwapStatusDTO>,
|
||||
): SwapTransactionModel {
|
||||
val status = txStatuses[value.txId]
|
||||
val refundCurrency = status?.refundTokensResponse?.let { id ->
|
||||
responseCryptoCurrenciesFactory.createCurrency(
|
||||
responseToken = id,
|
||||
scanResponse = scanResponse,
|
||||
)
|
||||
}
|
||||
val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency)
|
||||
|
||||
return SwapTransactionModel(
|
||||
txId = value.txId,
|
||||
timestamp = value.timestamp,
|
||||
fromCryptoAmount = value.fromCryptoAmount,
|
||||
toCryptoAmount = value.toCryptoAmount,
|
||||
provider = value.provider,
|
||||
status = statusWithRefundCurrency?.let(statusConverter::convert),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.tangem.data.swap.converter.transaction
|
||||
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.common.currency.UserTokensResponseFactory
|
||||
import com.tangem.data.swap.models.SwapStatusDTO
|
||||
import com.tangem.data.swap.models.SwapTransactionDTO
|
||||
import com.tangem.data.swap.models.SwapTransactionListDTO
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.swap.models.SwapTransactionListModel
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal class SavedSwapTransactionListConverter(
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
) : Converter<SwapTransactionListModel, SwapTransactionListDTO> {
|
||||
|
||||
private val userTokensResponseFactory = UserTokensResponseFactory()
|
||||
private val savedSwapTransactionConverter by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SavedSwapTransactionConverter(responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory)
|
||||
}
|
||||
|
||||
override fun convert(value: SwapTransactionListModel) = SwapTransactionListDTO(
|
||||
userWalletId = value.userWalletId,
|
||||
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
|
||||
toCryptoCurrencyId = value.toCryptoCurrencyId,
|
||||
fromTokensResponse = userTokensResponseFactory.createResponseToken(
|
||||
value.fromCryptoCurrency,
|
||||
),
|
||||
toTokensResponse = userTokensResponseFactory.createResponseToken(
|
||||
value.toCryptoCurrency,
|
||||
),
|
||||
transactions = savedSwapTransactionConverter.convertList(value.transactions),
|
||||
)
|
||||
|
||||
fun convertBack(
|
||||
value: SwapTransactionListDTO,
|
||||
scanResponse: ScanResponse,
|
||||
txStatuses: Map<String, SwapStatusDTO>,
|
||||
): SwapTransactionListModel? {
|
||||
val fromToken = value.fromTokensResponse
|
||||
val toToken = value.toTokensResponse
|
||||
return if (fromToken == null || toToken == null) {
|
||||
null
|
||||
} else {
|
||||
val fromCryptoCurrency = responseCryptoCurrenciesFactory.createCurrency(
|
||||
responseToken = fromToken,
|
||||
scanResponse = scanResponse,
|
||||
) ?: return null
|
||||
val toCryptoCurrency = responseCryptoCurrenciesFactory.createCurrency(
|
||||
responseToken = toToken,
|
||||
scanResponse = scanResponse,
|
||||
) ?: return null
|
||||
|
||||
return SwapTransactionListModel(
|
||||
transactions = value.transactions.map { tx ->
|
||||
savedSwapTransactionConverter.convertBack(tx, scanResponse, txStatuses)
|
||||
},
|
||||
userWalletId = value.userWalletId,
|
||||
fromCryptoCurrencyId = value.fromCryptoCurrencyId,
|
||||
toCryptoCurrencyId = value.toCryptoCurrencyId,
|
||||
fromCryptoCurrency = fromCryptoCurrency,
|
||||
toCryptoCurrency = toCryptoCurrency,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun default(
|
||||
userWalletId: UserWalletId,
|
||||
fromCryptoCurrency: CryptoCurrency,
|
||||
toCryptoCurrency: CryptoCurrency,
|
||||
tokenTransactions: List<SwapTransactionDTO>,
|
||||
) = SwapTransactionListDTO(
|
||||
userWalletId = userWalletId.stringValue,
|
||||
fromCryptoCurrencyId = fromCryptoCurrency.id.value,
|
||||
toCryptoCurrencyId = toCryptoCurrency.id.value,
|
||||
fromTokensResponse = userTokensResponseFactory.createResponseToken(fromCryptoCurrency),
|
||||
toTokensResponse = userTokensResponseFactory.createResponseToken(toCryptoCurrency),
|
||||
transactions = tokenTransactions,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package com.tangem.data.swap.di
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.express.converter.ExpressErrorConverter
|
||||
import com.tangem.data.swap.DefaultSwapErrorResolver
|
||||
import com.tangem.data.swap.DefaultSwapRepositoryV2
|
||||
import com.tangem.data.swap.DefaultSwapTransactionRepository
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.domain.express.ExpressRepository
|
||||
import com.tangem.domain.swap.SwapErrorResolver
|
||||
import com.tangem.domain.swap.SwapRepositoryV2
|
||||
import com.tangem.domain.swap.SwapTransactionRepository
|
||||
import com.tangem.domain.tokens.operations.BaseCurrencyStatusOperations
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
internal object SwapDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSwapErrorResolver(@NetworkMoshi moshi: Moshi): SwapErrorResolver {
|
||||
val jsonAdapter = moshi.adapter(ExpressErrorResponse::class.java)
|
||||
return DefaultSwapErrorResolver(
|
||||
ExpressErrorConverter(jsonAdapter),
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSwapRepository(
|
||||
tangemExpressApi: TangemExpressApi,
|
||||
expressRepository: ExpressRepository,
|
||||
coroutineDispatcher: CoroutineDispatcherProvider,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
currencyStatusOperations: BaseCurrencyStatusOperations,
|
||||
): SwapRepositoryV2 {
|
||||
return DefaultSwapRepositoryV2(
|
||||
tangemExpressApi = tangemExpressApi,
|
||||
expressRepository = expressRepository,
|
||||
coroutineDispatcher = coroutineDispatcher,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
currencyStatusOperations = currencyStatusOperations,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSwapTransactionRepository(
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): SwapTransactionRepository {
|
||||
return DefaultSwapTransactionRepository(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.tangem.data.swap.models
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class LastSwappedCryptoCurrencyDTO(
|
||||
@Json(name = "userWalletId")
|
||||
val userWalletId: String,
|
||||
@Json(name = "cryptoCurrencyId")
|
||||
val cryptoCurrencyId: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.tangem.data.swap.models
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import org.joda.time.DateTime
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class SwapStatusDTO(
|
||||
@Json(name = "providerId")
|
||||
val providerId: String,
|
||||
@Json(name = "status")
|
||||
val status: SavedSwapStatus? = null,
|
||||
@Json(name = "txId")
|
||||
val txId: String? = null,
|
||||
@Json(name = "txExternalUrl")
|
||||
val txExternalUrl: String? = null,
|
||||
@Json(name = "txExternalId")
|
||||
val txExternalId: String? = null,
|
||||
@Json(name = "refundNetwork")
|
||||
val refundNetwork: String? = null,
|
||||
@Json(name = "refundContractAddress")
|
||||
val refundContractAddress: String? = null,
|
||||
@Json(name = "refundTokensResponse")
|
||||
val refundTokensResponse: UserTokensResponse.Token? = null,
|
||||
@Json(ignore = true)
|
||||
val refundCurrency: CryptoCurrency? = null,
|
||||
@Json(name = "createdAt")
|
||||
val createdAt: DateTime? = null,
|
||||
@Json(name = "averageDuration")
|
||||
val averageDuration: Int? = null,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
internal enum class SavedSwapStatus {
|
||||
@Json(name = "New")
|
||||
New,
|
||||
|
||||
@Json(name = "Waiting")
|
||||
Waiting,
|
||||
|
||||
@Json(name = "WaitingTxHash")
|
||||
WaitingTxHash,
|
||||
|
||||
@Json(name = "Confirming")
|
||||
Confirming,
|
||||
|
||||
@Json(name = "Verifying")
|
||||
Verifying,
|
||||
|
||||
@Json(name = "Exchanging")
|
||||
Exchanging,
|
||||
|
||||
@Json(name = "Failed")
|
||||
Failed,
|
||||
|
||||
@Json(name = "Sending")
|
||||
Sending,
|
||||
|
||||
@Json(name = "Finished")
|
||||
Finished,
|
||||
|
||||
@Json(name = "Refunded")
|
||||
Refunded,
|
||||
|
||||
@Json(name = "Cancelled")
|
||||
Cancelled,
|
||||
|
||||
@Json(name = "TxFailed")
|
||||
TxFailed,
|
||||
|
||||
@Json(name = "Unknown")
|
||||
Unknown,
|
||||
|
||||
@Json(name = "Paused")
|
||||
Paused,
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.tangem.data.swap.models
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.express.models.ExpressProvider
|
||||
import com.tangem.domain.swap.models.SwapStatusModel
|
||||
import java.math.BigDecimal
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class SwapTransactionListDTO(
|
||||
@Json(name = "userWalletId")
|
||||
val userWalletId: String,
|
||||
@Json(name = "fromCryptoCurrencyId")
|
||||
val fromCryptoCurrencyId: String,
|
||||
@Json(name = "toCryptoCurrencyId")
|
||||
val toCryptoCurrencyId: String,
|
||||
@Json(name = "fromTokensResponse")
|
||||
val fromTokensResponse: UserTokensResponse.Token? = null,
|
||||
@Json(name = "toTokensResponse")
|
||||
val toTokensResponse: UserTokensResponse.Token? = null,
|
||||
@Json(name = "transactions")
|
||||
val transactions: List<SwapTransactionDTO>,
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
internal data class SwapTransactionDTO(
|
||||
@Json(name = "txId")
|
||||
val txId: String,
|
||||
@Json(name = "timestamp")
|
||||
val timestamp: Long,
|
||||
@Json(name = "fromCryptoAmount")
|
||||
val fromCryptoAmount: BigDecimal,
|
||||
@Json(name = "toCryptoAmount")
|
||||
val toCryptoAmount: BigDecimal,
|
||||
@Json(name = "provider")
|
||||
val provider: ExpressProvider,
|
||||
@Json(name = "status")
|
||||
val status: SwapStatusModel? = null,
|
||||
)
|
||||
|
|
@ -12,6 +12,10 @@ android {
|
|||
namespace = "com.tangem.data.tokens"
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
// region Project - Data
|
||||
|
|
@ -62,4 +66,13 @@ dependencies {
|
|||
ksp(deps.moshi.kotlin.codegen)
|
||||
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
|
||||
// endregion
|
||||
|
||||
// region Tests
|
||||
testImplementation(deps.test.coroutine)
|
||||
testImplementation(deps.test.junit5)
|
||||
testRuntimeOnly(deps.test.junit5.engine)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
testImplementation(projects.common.test)
|
||||
// endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package com.tangem.data.tokens
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.UserTokensResponseFactory
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.data.tokens.utils.CustomTokensMerger
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
|
||||
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.core.utils.catchOn
|
||||
import com.tangem.domain.demo.DemoConfig
|
||||
import com.tangem.domain.models.scan.ScanResponse
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher.Params
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.isMultiCurrency
|
||||
import com.tangem.domain.wallets.models.requireColdWallet
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiWalletCryptoCurrenciesFetcher]
|
||||
*
|
||||
* @property userWalletsStore [UserWallet]'s store
|
||||
* @property tangemTechApi Tangem Tech API
|
||||
* @property userTokensResponseStore store of [UserTokensResponse]
|
||||
* @property userTokensSaver user tokens saver
|
||||
* @property cardCryptoCurrencyFactory factory for creating crypto currencies for specified card
|
||||
* @property expressServiceLoader express service loader
|
||||
* @property dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultMultiWalletCryptoCurrenciesFetcher(
|
||||
private val demoConfig: DemoConfig,
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val customTokensMerger: CustomTokensMerger,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val userTokensSaver: UserTokensSaver,
|
||||
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
|
||||
private val expressServiceLoader: ExpressServiceLoader,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiWalletCryptoCurrenciesFetcher {
|
||||
|
||||
private val userTokensResponseFactory = UserTokensResponseFactory()
|
||||
|
||||
override suspend fun invoke(params: Params) = Either.catchOn(dispatchers.default) {
|
||||
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
|
||||
if (!userWallet.isMultiCurrency) error("${this::class.simpleName} supports only multi-currency wallet")
|
||||
|
||||
val response = if (userWallet is UserWallet.Cold && userWallet.isDemoWalletWithoutSavedTokens()) {
|
||||
createDefaultUserTokensResponse(scanResponse = userWallet.scanResponse)
|
||||
} else {
|
||||
safeApiCall(
|
||||
call = {
|
||||
withContext(dispatchers.io) {
|
||||
tangemTechApi.getUserTokens(userId = userWallet.walletId.stringValue).bind()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
handleFetchTokensError(error = it, userWallet = userWallet.requireColdWallet()) // TODO 11142
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val compatibleUserTokensResponse = response
|
||||
.let { it.copy(tokens = it.tokens.distinct()) }
|
||||
.let { customTokensMerger.mergeIfPresented(userWalletId = userWallet.walletId, response = it) }
|
||||
|
||||
userTokensSaver.store(userWalletId = userWallet.walletId, response = compatibleUserTokensResponse)
|
||||
|
||||
fetchExpressAssetsByNetworkIds(userWallet = userWallet, userTokens = compatibleUserTokensResponse)
|
||||
}
|
||||
|
||||
private suspend fun UserWallet.Cold.isDemoWalletWithoutSavedTokens(): Boolean {
|
||||
val isDemoCard = demoConfig.isDemoCardId(cardId = cardId)
|
||||
|
||||
return if (isDemoCard) {
|
||||
val response = userTokensResponseStore.getSyncOrNull(userWalletId = walletId)
|
||||
|
||||
response == null
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleFetchTokensError(error: ApiResponseError, userWallet: UserWallet): UserTokensResponse {
|
||||
val userWalletId = userWallet.walletId
|
||||
|
||||
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
|
||||
?: createDefaultUserTokensResponse(scanResponse = userWallet.requireColdWallet().scanResponse)
|
||||
|
||||
if (error is ApiResponseError.HttpException && error.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
|
||||
Timber.w(error, "Requested currencies could not be found in the remote store for: $userWalletId")
|
||||
|
||||
userTokensSaver.push(userWalletId, response)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) {
|
||||
val tokens = userTokens.tokens.map { token ->
|
||||
LeastTokenInfo(
|
||||
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
|
||||
network = token.networkId,
|
||||
)
|
||||
}
|
||||
|
||||
expressServiceLoader.update(userWallet = userWallet, userTokens = tokens)
|
||||
}
|
||||
|
||||
private fun createDefaultUserTokensResponse(scanResponse: ScanResponse): UserTokensResponse {
|
||||
return userTokensResponseFactory.createUserTokensResponse(
|
||||
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse),
|
||||
isGroupedByNetwork = false,
|
||||
isSortedByBalance = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package com.tangem.data.tokens
|
||||
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.domain.wallets.models.requireColdWallet
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
/**
|
||||
* Default implementation of [MultiWalletCryptoCurrenciesProducer]
|
||||
*
|
||||
* @property params params
|
||||
* @property userWalletsStore UserWallet's store
|
||||
* @property userTokensResponseStore store of `UserTokensResponse`
|
||||
* @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse`
|
||||
* @property dispatchers dispatchers
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constructor(
|
||||
@Assisted val params: MultiWalletCryptoCurrenciesProducer.Params,
|
||||
private val userWalletsStore: UserWalletsStore,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : MultiWalletCryptoCurrenciesProducer {
|
||||
|
||||
override val fallback: Set<CryptoCurrency>
|
||||
get() = emptySet()
|
||||
|
||||
override fun produce(): Flow<Set<CryptoCurrency>> {
|
||||
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId).requireColdWallet() // TODO [REDACTED_TASK_KEY]
|
||||
|
||||
if (!userWallet.isMultiCurrency) {
|
||||
error("${this::class.simpleName} supports only multi-currency wallet")
|
||||
}
|
||||
|
||||
return userTokensResponseStore.get(userWalletId = params.userWalletId)
|
||||
.distinctUntilChanged()
|
||||
.map { response ->
|
||||
if (response == null) return@map emptySet()
|
||||
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = response,
|
||||
scanResponse = userWallet.scanResponse,
|
||||
).toSet()
|
||||
}
|
||||
.onEmpty { emit(fallback) }
|
||||
.flowOn(dispatchers.default)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : MultiWalletCryptoCurrenciesProducer.Factory {
|
||||
override fun create(
|
||||
params: MultiWalletCryptoCurrenciesProducer.Params,
|
||||
): DefaultMultiWalletCryptoCurrenciesProducer
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.tangem.data.tokens.di
|
||||
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesFetcher
|
||||
import com.tangem.data.tokens.utils.CustomTokensMerger
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.demo.DemoConfig
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal class MultiWalletCryptoCurrenciesFetcherModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideMultiWalletCryptoCurrenciesFetcher(
|
||||
tangemTechApi: TangemTechApi,
|
||||
userWalletsStore: UserWalletsStore,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
userTokensSaver: UserTokensSaver,
|
||||
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
|
||||
expressServiceLoader: ExpressServiceLoader,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): MultiWalletCryptoCurrenciesFetcher {
|
||||
return DefaultMultiWalletCryptoCurrenciesFetcher(
|
||||
demoConfig = DemoConfig(),
|
||||
userWalletsStore = userWalletsStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
customTokensMerger = CustomTokensMerger(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userTokensSaver = userTokensSaver,
|
||||
dispatchers = dispatchers,
|
||||
),
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
userTokensSaver = userTokensSaver,
|
||||
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
|
||||
expressServiceLoader = expressServiceLoader,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.tokens.di
|
||||
|
||||
import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal interface MultiWalletCryptoCurrenciesProducerModule {
|
||||
|
||||
@Singleton
|
||||
@Binds
|
||||
fun bindMultiWalletCryptoCurrenciesProducerFactory(
|
||||
impl: DefaultMultiWalletCryptoCurrenciesProducer.Factory,
|
||||
): MultiWalletCryptoCurrenciesProducer.Factory
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.tangem.data.tokens.di
|
||||
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
internal class MultiWalletCryptoCurrenciesSupplierModule {
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideMultiWalletCryptoCurrenciesSupplier(
|
||||
factory: MultiWalletCryptoCurrenciesProducer.Factory,
|
||||
): MultiWalletCryptoCurrenciesSupplier {
|
||||
return object : MultiWalletCryptoCurrenciesSupplier(
|
||||
factory = factory,
|
||||
keyCreator = { "multi_crypto_currency_${it.userWalletId}" },
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.tangem.data.tokens.di
|
|||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.data.common.cache.CacheRegistry
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
|
||||
import com.tangem.data.tokens.repository.DefaultCurrencyChecksRepository
|
||||
|
|
@ -10,6 +11,7 @@ import com.tangem.data.tokens.repository.DefaultPolkadotAccountHealthCheckReposi
|
|||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.tokens.repository.CurrenciesRepository
|
||||
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
|
||||
|
|
@ -30,7 +32,7 @@ internal object TokensDataModule {
|
|||
@Singleton
|
||||
fun provideCurrenciesRepository(
|
||||
tangemTechApi: TangemTechApi,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
userTokensResponseStore: UserTokensResponseStore,
|
||||
userWalletsStore: UserWalletsStore,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
cacheRegistry: CacheRegistry,
|
||||
|
|
@ -39,18 +41,20 @@ internal object TokensDataModule {
|
|||
excludedBlockchains: ExcludedBlockchains,
|
||||
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
|
||||
tokensSaver: UserTokensSaver,
|
||||
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
): CurrenciesRepository {
|
||||
return DefaultCurrenciesRepository(
|
||||
tangemTechApi = tangemTechApi,
|
||||
userWalletsStore = userWalletsStore,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
cacheRegistry = cacheRegistry,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
expressServiceLoader = expressServiceLoader,
|
||||
dispatchers = dispatchers,
|
||||
excludedBlockchains = excludedBlockchains,
|
||||
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
|
||||
userTokensSaver = tokensSaver,
|
||||
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@ import com.tangem.datasource.api.express.models.request.LeastTokenInfo
|
|||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getObject
|
||||
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.common.CardTypesResolver
|
||||
import com.tangem.domain.common.util.cardTypesResolver
|
||||
import com.tangem.domain.core.error.DataError
|
||||
import com.tangem.domain.demo.DemoConfig
|
||||
|
|
@ -41,16 +39,16 @@ internal class DefaultCurrenciesRepository(
|
|||
private val userWalletsStore: UserWalletsStore,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val cacheRegistry: CacheRegistry,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val expressServiceLoader: ExpressServiceLoader,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
|
||||
private val userTokensSaver: UserTokensSaver,
|
||||
private val userTokensResponseStore: UserTokensResponseStore,
|
||||
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
|
||||
excludedBlockchains: ExcludedBlockchains,
|
||||
) : CurrenciesRepository {
|
||||
|
||||
private val demoConfig = DemoConfig()
|
||||
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory(excludedBlockchains)
|
||||
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
|
||||
private val userTokensResponseFactory = UserTokensResponseFactory()
|
||||
private val customTokensMerger = CustomTokensMerger(
|
||||
|
|
@ -92,7 +90,10 @@ internal class DefaultCurrenciesRepository(
|
|||
response = updatedResponse,
|
||||
)
|
||||
|
||||
fetchExpressAssetsByNetworkIds(userWalletId, updatedResponse)
|
||||
fetchExpressAssetsByNetworkIds(
|
||||
userWallet = userWalletsStore.getSyncStrict(key = userWalletId),
|
||||
userTokens = updatedResponse,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +116,11 @@ internal class DefaultCurrenciesRepository(
|
|||
userWalletId = userWalletId,
|
||||
response = updatedResponse,
|
||||
)
|
||||
fetchExpressAssetsByNetworkIds(userWalletId, updatedResponse)
|
||||
|
||||
fetchExpressAssetsByNetworkIds(
|
||||
userWallet = userWalletsStore.getSyncStrict(key = userWalletId),
|
||||
userTokens = updatedResponse,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +206,7 @@ internal class DefaultCurrenciesRepository(
|
|||
|
||||
override fun getWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
|
||||
return channelFlow {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
|
||||
if (userWallet.isMultiCurrency) {
|
||||
getMultiCurrencyWalletCurrenciesUpdates(userWalletId).collect(::send)
|
||||
|
|
@ -218,13 +223,19 @@ internal class DefaultCurrenciesRepository(
|
|||
refresh: Boolean,
|
||||
): CryptoCurrency {
|
||||
return withContext(dispatchers.io) {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
|
||||
|
||||
val currency = cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(
|
||||
userWallet.requireColdWallet().scanResponse,
|
||||
)
|
||||
fetchExpressAssetsByNetworkIds(userWalletId, listOf(currency), refresh)
|
||||
|
||||
fetchExpressAssetsByNetworkIds(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencies = listOf(currency),
|
||||
refresh = refresh,
|
||||
)
|
||||
|
||||
currency
|
||||
}
|
||||
}
|
||||
|
|
@ -234,7 +245,8 @@ internal class DefaultCurrenciesRepository(
|
|||
refresh: Boolean,
|
||||
): List<CryptoCurrency> {
|
||||
return withContext(dispatchers.io) {
|
||||
val scanResponse = getUserWallet(userWalletId).requireColdWallet().scanResponse
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
val scanResponse = userWallet.requireColdWallet().scanResponse
|
||||
|
||||
val currencies = if (scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
|
||||
cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(scanResponse = scanResponse)
|
||||
|
|
@ -244,7 +256,12 @@ internal class DefaultCurrenciesRepository(
|
|||
}
|
||||
}
|
||||
|
||||
fetchExpressAssetsByNetworkIds(userWalletId, currencies, refresh)
|
||||
fetchExpressAssetsByNetworkIds(
|
||||
userWallet = userWallet,
|
||||
cryptoCurrencies = currencies,
|
||||
refresh = refresh,
|
||||
)
|
||||
|
||||
currencies
|
||||
}
|
||||
}
|
||||
|
|
@ -254,7 +271,7 @@ internal class DefaultCurrenciesRepository(
|
|||
id: CryptoCurrency.ID,
|
||||
): CryptoCurrency {
|
||||
return withContext(dispatchers.io) {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
|
||||
|
||||
val currency = cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(
|
||||
|
|
@ -262,14 +279,14 @@ internal class DefaultCurrenciesRepository(
|
|||
)
|
||||
.find { it.id == id }
|
||||
requireNotNull(currency) { "Unable to find currency with provided ID: $id" }
|
||||
fetchExpressAssetsByNetworkIds(userWalletId, listOf(currency))
|
||||
fetchExpressAssetsByNetworkIds(userWallet, listOf(currency))
|
||||
currency
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
|
||||
return channelFlow {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
|
||||
|
||||
getMultiCurrencyWalletCurrencies(userWallet)
|
||||
|
|
@ -286,7 +303,7 @@ internal class DefaultCurrenciesRepository(
|
|||
userWalletId: UserWalletId,
|
||||
refresh: Boolean,
|
||||
): List<CryptoCurrency> = withContext(dispatchers.io) {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
|
||||
|
||||
fetchTokensIfCacheExpired(userWallet, refresh)
|
||||
|
|
@ -298,12 +315,12 @@ internal class DefaultCurrenciesRepository(
|
|||
},
|
||||
)
|
||||
|
||||
responseCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
|
||||
responseCryptoCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
|
||||
}
|
||||
|
||||
override suspend fun getMultiCurrencyWalletCachedCurrenciesSync(userWalletId: UserWalletId) =
|
||||
withContext(dispatchers.io) {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
|
||||
|
||||
val storedTokens = requireNotNull(
|
||||
|
|
@ -313,7 +330,7 @@ internal class DefaultCurrenciesRepository(
|
|||
},
|
||||
)
|
||||
|
||||
responseCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
|
||||
responseCryptoCurrenciesFactory.createCurrencies(storedTokens, userWallet.requireColdWallet().scanResponse)
|
||||
}
|
||||
|
||||
override suspend fun getMultiCurrencyWalletCurrency(
|
||||
|
|
@ -325,7 +342,7 @@ internal class DefaultCurrenciesRepository(
|
|||
|
||||
override suspend fun getMultiCurrencyWalletCurrency(userWalletId: UserWalletId, id: String): CryptoCurrency =
|
||||
withContext(dispatchers.io) {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
|
||||
|
||||
val response = requireNotNull(
|
||||
|
|
@ -335,7 +352,7 @@ internal class DefaultCurrenciesRepository(
|
|||
},
|
||||
)
|
||||
|
||||
responseCurrenciesFactory.createCurrency(
|
||||
responseCryptoCurrenciesFactory.createCurrency(
|
||||
currencyId = id,
|
||||
response = response,
|
||||
scanResponse = userWallet.requireColdWallet().scanResponse,
|
||||
|
|
@ -348,7 +365,7 @@ internal class DefaultCurrenciesRepository(
|
|||
derivationPath: Network.DerivationPath,
|
||||
): CryptoCurrency.Coin {
|
||||
return withContext(dispatchers.io) {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
ensureIsCorrectUserWallet(userWallet = userWallet, isMultiCurrencyWalletExpected = true)
|
||||
|
||||
fetchTokensIfCacheExpired(userWallet = userWallet, refresh = false)
|
||||
|
|
@ -370,7 +387,10 @@ internal class DefaultCurrenciesRepository(
|
|||
it.derivationPath == derivationPath.value
|
||||
} ?: error("Coin in this network $networkId not found")
|
||||
|
||||
val coin = responseCurrenciesFactory.createCurrency(storedCoin, userWallet.requireColdWallet().scanResponse)
|
||||
val coin = responseCryptoCurrenciesFactory.createCurrency(
|
||||
responseToken = storedCoin,
|
||||
scanResponse = userWallet.requireColdWallet().scanResponse,
|
||||
)
|
||||
|
||||
coin as? CryptoCurrency.Coin ?: error("Unable to create currency")
|
||||
}
|
||||
|
|
@ -378,7 +398,7 @@ internal class DefaultCurrenciesRepository(
|
|||
|
||||
override fun isTokensGrouped(userWalletId: UserWalletId): Flow<Boolean> {
|
||||
return channelFlow {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
|
||||
if (userWallet.isMultiCurrency) {
|
||||
getSavedUserTokensResponse(userWalletId)
|
||||
|
|
@ -394,7 +414,7 @@ internal class DefaultCurrenciesRepository(
|
|||
|
||||
override fun isTokensSortedByBalance(userWalletId: UserWalletId): Flow<Boolean> {
|
||||
return channelFlow {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
|
||||
if (userWallet.isMultiCurrency) {
|
||||
getSavedUserTokensResponse(userWalletId)
|
||||
|
|
@ -466,7 +486,7 @@ internal class DefaultCurrenciesRepository(
|
|||
contractAddress: String,
|
||||
networkId: String,
|
||||
): CryptoCurrency.Token {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
val token = withContext(dispatchers.io) {
|
||||
val foundToken = tangemTechApi.getCoins(
|
||||
contractAddress = contractAddress,
|
||||
|
|
@ -525,7 +545,7 @@ internal class DefaultCurrenciesRepository(
|
|||
getL2CompatibilityTokenComparison(it, currencyRawId.value)
|
||||
}
|
||||
|
||||
responseCurrenciesFactory.createCurrencies(
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = storedTokens.copy(tokens = filterResponse),
|
||||
scanResponse = userWallet.requireColdWallet().scanResponse,
|
||||
)
|
||||
|
|
@ -570,9 +590,13 @@ internal class DefaultCurrenciesRepository(
|
|||
)
|
||||
}
|
||||
|
||||
override fun getCardTypesResolver(userWalletId: UserWalletId): CardTypesResolver {
|
||||
return userWalletsStore.getSyncStrict(userWalletId).requireColdWallet().cardTypesResolver // TODO [REDACTED_TASK_KEY]
|
||||
}
|
||||
|
||||
private fun getMultiCurrencyWalletCurrencies(userWallet: UserWallet): Flow<List<CryptoCurrency>> {
|
||||
return getSavedUserTokensResponse(userWallet.walletId).map { storedTokens ->
|
||||
responseCurrenciesFactory.createCurrencies(
|
||||
responseCryptoCurrenciesFactory.createCurrencies(
|
||||
response = storedTokens,
|
||||
scanResponse = userWallet.requireColdWallet().scanResponse,
|
||||
)
|
||||
|
|
@ -611,7 +635,7 @@ internal class DefaultCurrenciesRepository(
|
|||
|
||||
userTokensSaver.store(userWalletId, compatibleUserTokensResponse)
|
||||
|
||||
fetchExpressAssetsByNetworkIds(userWalletId, compatibleUserTokensResponse)
|
||||
fetchExpressAssetsByNetworkIds(userWallet, compatibleUserTokensResponse)
|
||||
}
|
||||
|
||||
private suspend fun checkIsEmptyDemoWallet(userWallet: UserWallet.Cold): Boolean {
|
||||
|
|
@ -620,7 +644,7 @@ internal class DefaultCurrenciesRepository(
|
|||
return demoConfig.isDemoCardId(userWallet.cardId) && response == null
|
||||
}
|
||||
|
||||
private suspend fun fetchExpressAssetsByNetworkIds(userWalletId: UserWalletId, userTokens: UserTokensResponse) {
|
||||
private suspend fun fetchExpressAssetsByNetworkIds(userWallet: UserWallet, userTokens: UserTokensResponse) {
|
||||
val tokens = userTokens.tokens.map { token ->
|
||||
LeastTokenInfo(
|
||||
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
|
||||
|
|
@ -629,12 +653,12 @@ internal class DefaultCurrenciesRepository(
|
|||
}
|
||||
|
||||
coroutineScope {
|
||||
launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) }
|
||||
launch { expressServiceLoader.update(userWallet, tokens) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchExpressAssetsByNetworkIds(
|
||||
userWalletId: UserWalletId,
|
||||
userWallet: UserWallet,
|
||||
cryptoCurrencies: List<CryptoCurrency>,
|
||||
refresh: Boolean = false,
|
||||
) {
|
||||
|
|
@ -646,11 +670,11 @@ internal class DefaultCurrenciesRepository(
|
|||
)
|
||||
}
|
||||
cacheRegistry.invokeOnExpire(
|
||||
key = getAssetsCacheKey(userWalletId),
|
||||
key = getAssetsCacheKey(userWallet.walletId),
|
||||
skipCache = refresh,
|
||||
block = {
|
||||
coroutineScope {
|
||||
launch { expressServiceLoader.update(getUserWallet(userWalletId), tokens) }
|
||||
launch { expressServiceLoader.update(userWallet, tokens) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -660,9 +684,8 @@ internal class DefaultCurrenciesRepository(
|
|||
|
||||
private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse {
|
||||
val userWalletId = userWallet.walletId
|
||||
val response = appPreferencesStore.getObjectSyncOrNull(
|
||||
key = PreferencesKeys.getUserTokensKey(userWalletId.stringValue),
|
||||
) ?: createDefaultUserTokensResponse(userWallet)
|
||||
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
|
||||
?: createDefaultUserTokensResponse(userWallet = userWallet)
|
||||
|
||||
if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
|
||||
Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId")
|
||||
|
|
@ -678,20 +701,14 @@ internal class DefaultCurrenciesRepository(
|
|||
private fun createDefaultUserTokensResponse(userWallet: UserWallet) =
|
||||
userTokensResponseFactory.createUserTokensResponse(
|
||||
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(
|
||||
userWallet.requireColdWallet().scanResponse,
|
||||
userWallet.requireColdWallet().scanResponse, // TODO [REDACTED_TASK_KEY]
|
||||
),
|
||||
isGroupedByNetwork = false,
|
||||
isSortedByBalance = false,
|
||||
)
|
||||
|
||||
private fun getUserWallet(userWalletId: UserWalletId): UserWallet {
|
||||
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
|
||||
"Unable to find a user wallet with provided ID: $userWalletId"
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureIsCorrectUserWallet(userWalletId: UserWalletId, isMultiCurrencyWalletExpected: Boolean) {
|
||||
val userWallet = getUserWallet(userWalletId)
|
||||
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
|
||||
|
||||
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected)
|
||||
}
|
||||
|
|
@ -720,14 +737,10 @@ internal class DefaultCurrenciesRepository(
|
|||
private fun getTokensCacheKey(userWalletId: UserWalletId): String = "tokens_cache_key_${userWalletId.stringValue}"
|
||||
|
||||
private fun getSavedUserTokensResponse(key: UserWalletId): Flow<UserTokensResponse> {
|
||||
return appPreferencesStore
|
||||
.getObject<UserTokensResponse>(PreferencesKeys.getUserTokensKey(userWalletId = key.stringValue))
|
||||
.filterNotNull()
|
||||
return userTokensResponseStore.get(userWalletId = key).filterNotNull()
|
||||
}
|
||||
|
||||
private suspend fun getSavedUserTokensResponseSync(key: UserWalletId): UserTokensResponse? {
|
||||
return appPreferencesStore.getObjectSyncOrNull<UserTokensResponse>(
|
||||
key = PreferencesKeys.getUserTokensKey(key.stringValue),
|
||||
)
|
||||
return userTokensResponseStore.getSyncOrNull(userWalletId = key)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import arrow.atomic.AtomicBoolean
|
|||
import com.tangem.data.common.api.safeApiCall
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
|
|
@ -15,11 +16,15 @@ import timber.log.Timber
|
|||
/**
|
||||
* Responsible for merging custom tokens into a user's token response.
|
||||
* It handles the logic to update tokens with additional details if necessary.
|
||||
*
|
||||
* @property tangemTechApi Tangem Tech API
|
||||
* @property userTokensSaver user tokens saver
|
||||
* @property dispatchers dispatchers
|
||||
*/
|
||||
internal class CustomTokensMerger(
|
||||
private val tangemTechApi: TangemTechApi,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
private val userTokensSaver: UserTokensSaver,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
@ -29,21 +34,17 @@ internal class CustomTokensMerger(
|
|||
* is needed, and if so, updating the token from [TangemTechApi.getCoins] response. It then pushes to the backend
|
||||
* and returns an updated UserTokensResponse.
|
||||
*
|
||||
* @param userWalletId The identifier for the user's wallet, used when pushing updates.
|
||||
* @param response The original user tokens response that may need to be updated.
|
||||
* @return A potentially updated UserTokensResponse, with custom tokens merged if necessary.
|
||||
* @param userWalletId the identifier for the user's wallet, used when pushing updates
|
||||
* @param response the original user tokens response that may need to be updated
|
||||
*
|
||||
* @return a potentially updated UserTokensResponse, with custom tokens merged if necessary
|
||||
*/
|
||||
suspend fun mergeIfPresented(userWalletId: UserWalletId, response: UserTokensResponse): UserTokensResponse {
|
||||
// use flag to check: we can't compare two token list after merge because Token equals don't include some fields
|
||||
val wasMerged = AtomicBoolean(false)
|
||||
|
||||
val mergedTokens = withContext(dispatchers.default) {
|
||||
response.tokens
|
||||
.map { token ->
|
||||
async { mergeIfPresented(token, wasMerged) }
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
val mergedTokens = mergeTokens(response = response, wasMerged = wasMerged)
|
||||
|
||||
val updatedResponse = response.copy(tokens = mergedTokens)
|
||||
|
||||
// previously here was used compare response.tokens, but it's not working correctly
|
||||
|
|
@ -55,33 +56,48 @@ internal class CustomTokensMerger(
|
|||
return updatedResponse
|
||||
}
|
||||
|
||||
private suspend fun mergeIfPresented(
|
||||
token: UserTokensResponse.Token,
|
||||
private suspend fun mergeTokens(
|
||||
response: UserTokensResponse,
|
||||
wasMerged: AtomicBoolean,
|
||||
): UserTokensResponse.Token {
|
||||
if (isCoinOrNonCustomToken(token)) return token
|
||||
): List<UserTokensResponse.Token> {
|
||||
return withContext(dispatchers.default) {
|
||||
response.tokens
|
||||
.map { token ->
|
||||
async {
|
||||
if (isCoinOrNonCustomToken(token)) return@async token
|
||||
|
||||
return merge(token, wasMerged)
|
||||
}
|
||||
|
||||
private suspend fun merge(
|
||||
customToken: UserTokensResponse.Token,
|
||||
wasMerged: AtomicBoolean,
|
||||
): UserTokensResponse.Token {
|
||||
val foundToken = fetchToken(customToken)
|
||||
if (foundToken != null) {
|
||||
wasMerged.set(true)
|
||||
mergeToken(customToken = token, wasMerged = wasMerged)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
return foundToken ?: customToken
|
||||
}
|
||||
|
||||
private fun isCoinOrNonCustomToken(token: UserTokensResponse.Token): Boolean {
|
||||
return token.contractAddress.isNullOrEmpty() || token.id != null
|
||||
}
|
||||
|
||||
private suspend fun fetchToken(token: UserTokensResponse.Token): UserTokensResponse.Token? {
|
||||
val response = withContext(dispatchers.io) {
|
||||
safeApiCall(
|
||||
private suspend fun mergeToken(
|
||||
customToken: UserTokensResponse.Token,
|
||||
wasMerged: AtomicBoolean,
|
||||
): UserTokensResponse.Token {
|
||||
val foundToken = findToken(token = customToken)
|
||||
|
||||
return if (foundToken != null) {
|
||||
wasMerged.set(true)
|
||||
customToken.mergeWith(foundToken)
|
||||
} else {
|
||||
customToken
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a [token] among the cryptocurrencies available for application.
|
||||
* If result is not null, then the token is available and should not be custom.
|
||||
*/
|
||||
private suspend fun findToken(token: UserTokensResponse.Token): CoinsResponse.Coin? {
|
||||
return withContext(dispatchers.io) {
|
||||
val response = safeApiCall(
|
||||
call = {
|
||||
tangemTechApi.getCoins(
|
||||
contractAddress = token.contractAddress,
|
||||
|
|
@ -89,19 +105,20 @@ internal class CustomTokensMerger(
|
|||
).bind()
|
||||
},
|
||||
onError = {
|
||||
Timber.w(it, "Unable to fetch token")
|
||||
Timber.e(it, "Unable to fetch token:\n$token")
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
val foundToken = response?.coins?.firstOrNull() ?: return null
|
||||
|
||||
return token.copy(
|
||||
id = foundToken.id,
|
||||
name = foundToken.name,
|
||||
symbol = foundToken.symbol.ifEmpty {
|
||||
token.symbol
|
||||
},
|
||||
response?.coins?.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun UserTokensResponse.Token.mergeWith(coin: CoinsResponse.Coin): UserTokensResponse.Token {
|
||||
return copy(
|
||||
id = coin.id,
|
||||
name = coin.name,
|
||||
symbol = coin.symbol.ifEmpty { symbol },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.tangem.data.tokens.utils
|
||||
|
||||
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
|
||||
import com.tangem.domain.tokens.model.FoundToken
|
||||
import com.tangem.utils.converter.Converter
|
||||
|
||||
internal object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
|
||||
|
||||
override fun convert(value: CoinsResponse.Coin): FoundToken {
|
||||
return FoundToken(
|
||||
id = value.id,
|
||||
name = value.name,
|
||||
symbol = value.symbol,
|
||||
contractAddress = requireNotNull(value.networks.first().contractAddress),
|
||||
decimals = requireNotNull(value.networks.first().decimalCount).intValueExact(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
package com.tangem.data.tokens
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.common.test.utils.assertEither
|
||||
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
|
||||
import com.tangem.data.common.currency.UserTokensResponseFactory
|
||||
import com.tangem.data.common.currency.UserTokensSaver
|
||||
import com.tangem.data.tokens.utils.CustomTokensMerger
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.express.models.TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
|
||||
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
|
||||
import com.tangem.datasource.api.tangemTech.TangemTechApi
|
||||
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
|
||||
import com.tangem.datasource.exchangeservice.swap.ExpressServiceLoader
|
||||
import com.tangem.datasource.local.token.UserTokensResponseStore
|
||||
import com.tangem.datasource.local.userwallet.UserWalletsStore
|
||||
import com.tangem.domain.demo.DemoConfig
|
||||
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesFetcher
|
||||
import com.tangem.domain.wallets.models.UserWallet
|
||||
import com.tangem.domain.wallets.models.UserWalletId
|
||||
import com.tangem.domain.wallets.models.isMultiCurrency
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
/**
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultMultiWalletCryptoCurrenciesFetcherTest {
|
||||
|
||||
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
||||
private val userTokensResponseFactory = UserTokensResponseFactory()
|
||||
|
||||
private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true)
|
||||
private val tangemTechApi: TangemTechApi = mockk()
|
||||
private val customTokensMerger: CustomTokensMerger = mockk()
|
||||
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
|
||||
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
|
||||
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
|
||||
private val expressServiceLoader: ExpressServiceLoader = mockk(relaxUnitFun = true)
|
||||
|
||||
private val fetcher = DefaultMultiWalletCryptoCurrenciesFetcher(
|
||||
demoConfig = DemoConfig(),
|
||||
userWalletsStore = userWalletsStore,
|
||||
tangemTechApi = tangemTechApi,
|
||||
customTokensMerger = customTokensMerger,
|
||||
userTokensResponseStore = userTokensResponseStore,
|
||||
userTokensSaver = userTokensSaver,
|
||||
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
|
||||
expressServiceLoader = expressServiceLoader,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(
|
||||
userWalletsStore,
|
||||
tangemTechApi,
|
||||
userTokensResponseStore,
|
||||
userTokensSaver,
|
||||
cardCryptoCurrencyFactory,
|
||||
expressServiceLoader,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch failure if UserWallet ISN'T MULTI-CURRENCY wallet`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet> {
|
||||
every { isMultiCurrency } returns false
|
||||
}
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = IllegalStateException("${this::class.simpleName} supports only multi-currency wallet").left()
|
||||
assertEither(actual, expected)
|
||||
|
||||
verifyOrder { userWalletsStore.getSyncStrict(key = params.userWalletId) }
|
||||
coVerify(inverse = true) {
|
||||
userTokensResponseStore.getSyncOrNull(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if CARD IS DEMO and STORED TOKENS ARE EMPTY`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet.Cold> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
every { cardId } returns "AC01000000041225"
|
||||
}
|
||||
|
||||
val defaultCoins = listOf(
|
||||
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
|
||||
cryptoCurrencyFactory.createCoin(Blockchain.Ethereum),
|
||||
)
|
||||
|
||||
val userTokensResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.NONE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
tokens = listOf(
|
||||
userTokensResponseFactory.createResponseToken(defaultCoins.first()),
|
||||
userTokensResponseFactory.createResponseToken(defaultCoins.last()),
|
||||
),
|
||||
)
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId) } returns null
|
||||
every {
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
|
||||
} returns defaultCoins
|
||||
coEvery {
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
} returns userTokensResponse
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
assertEither(actual, expected)
|
||||
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId)
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = mockUserWallet.scanResponse)
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if CARD IS DEMO and STORED TOKENS AREN'T EMPTY`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet.Cold> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
every { cardId } returns "AC01000000041225"
|
||||
}
|
||||
|
||||
val apiResponse = ApiResponse.Success(
|
||||
data = defaultResponse.copy(group = UserTokensResponse.GroupType.TOKEN),
|
||||
)
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId) } returns defaultResponse
|
||||
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
|
||||
coEvery {
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
|
||||
} returns apiResponse.data
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
assertEither(actual, expected)
|
||||
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
userTokensResponseStore.getSyncOrNull(userWalletId = params.userWalletId)
|
||||
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
|
||||
userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data)
|
||||
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if CARD ISN'T DEMO`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet.Cold> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
every { cardId } returns "cardID"
|
||||
}
|
||||
|
||||
val apiResponse = ApiResponse.Success(
|
||||
data = defaultResponse.copy(group = UserTokensResponse.GroupType.TOKEN),
|
||||
)
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
|
||||
coEvery {
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
|
||||
} returns apiResponse.data
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
assertEither(actual, expected)
|
||||
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = apiResponse.data)
|
||||
userTokensSaver.store(userWalletId = params.userWalletId, response = apiResponse.data)
|
||||
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
userTokensResponseStore.getSyncOrNull(userWalletId = any())
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if API request RETURNS TIMEOUT EXCEPTION and STORED TOKENS ARE EMPTY`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet.Cold> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
every { cardId } returns "cardID"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val apiResponse = ApiResponse.Error(
|
||||
cause = ApiResponseError.TimeoutException,
|
||||
) as ApiResponse<UserTokensResponse>
|
||||
|
||||
val defaultCoins = listOf(
|
||||
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
|
||||
cryptoCurrencyFactory.createCoin(Blockchain.Ethereum),
|
||||
)
|
||||
|
||||
val userTokensResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.NONE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
tokens = listOf(
|
||||
userTokensResponseFactory.createResponseToken(defaultCoins.first()),
|
||||
userTokensResponseFactory.createResponseToken(defaultCoins.last()),
|
||||
),
|
||||
)
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
|
||||
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns null
|
||||
coEvery {
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
|
||||
} returns defaultCoins
|
||||
coEvery {
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
} returns userTokensResponse
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
assertEither(actual, expected)
|
||||
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
|
||||
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
userTokensSaver.push(userWalletId = any(), response = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if API request RETURNS TIMEOUT EXCEPTION and STORED TOKENS AREN'T EMPTY`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet.Cold> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
every { cardId } returns "cardID"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val apiResponse = ApiResponse.Error(
|
||||
cause = ApiResponseError.TimeoutException,
|
||||
) as ApiResponse<UserTokensResponse>
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
|
||||
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns defaultResponse
|
||||
coEvery {
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
|
||||
} returns defaultResponse
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
assertEither(actual, expected)
|
||||
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
|
||||
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
|
||||
userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse)
|
||||
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
userTokensSaver.push(userWalletId = any(), response = any())
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if API request RETURNS NOT FOUND EXCEPTION and STORED TOKENS ARE EMPTY`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet.Cold> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
every { cardId } returns "cardID"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val apiResponse = ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.NOT_FOUND,
|
||||
message = null,
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<UserTokensResponse>
|
||||
|
||||
val defaultCoins = listOf(
|
||||
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
|
||||
cryptoCurrencyFactory.createCoin(Blockchain.Ethereum),
|
||||
)
|
||||
|
||||
val userTokensResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.NONE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
tokens = listOf(
|
||||
userTokensResponseFactory.createResponseToken(defaultCoins.first()),
|
||||
userTokensResponseFactory.createResponseToken(defaultCoins.last()),
|
||||
),
|
||||
)
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
|
||||
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns null
|
||||
coEvery {
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
|
||||
} returns defaultCoins
|
||||
coEvery {
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
} returns userTokensResponse
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
assertEither(actual, expected)
|
||||
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
|
||||
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(mockUserWallet.scanResponse)
|
||||
userTokensSaver.push(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
userTokensSaver.store(userWalletId = params.userWalletId, response = userTokensResponse)
|
||||
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = userTokensResponse.toLeastTokens())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch successfully if API request RETURNS NOT FOUND EXCEPTION and STORED TOKENS AREN'T EMPTY`() = runTest {
|
||||
// Arrange
|
||||
val params = MultiWalletCryptoCurrenciesFetcher.Params(userWalletId = userWalletId)
|
||||
|
||||
val mockUserWallet = mockk<UserWallet.Cold> {
|
||||
every { walletId } returns userWalletId
|
||||
every { isMultiCurrency } returns true
|
||||
every { cardId } returns "cardID"
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val apiResponse = ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.NOT_FOUND,
|
||||
message = null,
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<UserTokensResponse>
|
||||
|
||||
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
|
||||
coEvery { tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue) } returns apiResponse
|
||||
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId) } returns defaultResponse
|
||||
coEvery {
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
|
||||
} returns defaultResponse
|
||||
|
||||
// Act
|
||||
val actual = fetcher(params)
|
||||
|
||||
// Assert
|
||||
val expected = Unit.right()
|
||||
assertEither(actual, expected)
|
||||
|
||||
coVerifyOrder {
|
||||
userWalletsStore.getSyncStrict(key = params.userWalletId)
|
||||
tangemTechApi.getUserTokens(userId = params.userWalletId.stringValue)
|
||||
userTokensResponseStore.getSyncOrNull(userWalletId = userWalletId)
|
||||
userTokensSaver.push(userWalletId = params.userWalletId, response = defaultResponse)
|
||||
customTokensMerger.mergeIfPresented(userWalletId = params.userWalletId, response = defaultResponse)
|
||||
userTokensSaver.store(userWalletId = params.userWalletId, response = defaultResponse)
|
||||
expressServiceLoader.update(userWallet = mockUserWallet, userTokens = defaultResponse.toLeastTokens())
|
||||
}
|
||||
|
||||
coVerify(inverse = true) {
|
||||
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyCard(scanResponse = any())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val userWalletId = UserWalletId("011")
|
||||
|
||||
val defaultResponse = UserTokensResponse(
|
||||
group = UserTokensResponse.GroupType.NONE,
|
||||
sort = UserTokensResponse.SortType.MANUAL,
|
||||
tokens = listOf(
|
||||
UserTokensResponse.Token(
|
||||
id = null,
|
||||
networkId = "bitcoin",
|
||||
derivationPath = null,
|
||||
name = "Bitcoin",
|
||||
symbol = "BTC",
|
||||
decimals = 8,
|
||||
contractAddress = null,
|
||||
addresses = listOf(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun UserTokensResponse.toLeastTokens(): List<LeastTokenInfo> {
|
||||
return tokens.map { token ->
|
||||
LeastTokenInfo(
|
||||
contractAddress = token.contractAddress ?: EMPTY_CONTRACT_ADDRESS_VALUE,
|
||||
network = token.networkId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue