Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-02 13:40:00 +03:00
commit 86d72b171f
657 changed files with 19875 additions and 4004 deletions

View file

@ -39,6 +39,7 @@ internal object BlockAidMapper {
} else {
mapSimulationSuccessResult(from.simulation.accountSummary)
},
description = from.validation.description,
)
}

View file

@ -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(

View file

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

View file

@ -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> {

View file

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

View file

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

View file

@ -0,0 +1,240 @@
package com.tangem.data.common.quote
import androidx.annotation.VisibleForTesting
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import com.tangem.data.common.api.safeApiCallWithTimeout
import com.tangem.data.common.quote.QuotesFetcher.Error
import com.tangem.data.common.quote.QuotesFetcher.Field
import com.tangem.data.common.quote.utils.combine
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.domain.core.utils.eitherOn
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.joda.time.DateTime
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds
/**
* Default implementation of [QuotesFetcher]
*
* @property tangemTechApi Tangem tech API
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultQuotesFetcher(
private val tangemTechApi: TangemTechApi,
private val dispatchers: CoroutineDispatcherProvider,
) : QuotesFetcher {
/** Mutex for synchronization of the [getFetchMutex] method */
private val getFetchMethodMutex = Mutex()
/**
* Map for tracking active [fetch] methods.
* The key is the parameters of the [fetch] method, and the value is [Mutex].
*/
private val fetchMutexMap = ConcurrentHashMap<RequestParams, Mutex>()
/**
* Cache that stores a list of quotes for a certain fiat currency.
* Quotes are considered expired if they are in the cache for more than [tenSecInMillis] seconds.
*/
private val quotesCache = ConcurrentHashMap<String, Set<QuoteMetadata>>()
override suspend fun fetch(
fiatCurrencyId: String,
currenciesIds: Set<String>,
fields: Set<Field>,
): Either<Error, QuotesResponse> = eitherOn(dispatchers.default) {
val validatedParams = validateParams(fiatCurrencyId, currenciesIds, fields)
if (validatedParams.currenciesIds.isEmpty()) return@eitherOn emptyQuotesResponse
val mutex = getFetchMutex(params = validatedParams)
return@eitherOn fetch(params = validatedParams, mutex = mutex)
}
.onLeft {
val params = RequestParams(fiatCurrencyId, currenciesIds, fields)
fetchMutexMap.remove(params)
}
private fun Raise<Error>.validateParams(
fiatCurrencyId: String,
currenciesIds: Set<String>,
fields: Set<Field>,
): RequestParams {
ensure(fiatCurrencyId.isNotBlank() && fields.isNotEmpty()) {
raise(Error.InvalidArgumentsError)
}
val filterCurrenciesIds = currenciesIds.filter(String::isNotEmpty).toSet()
return RequestParams(fiatCurrencyId = fiatCurrencyId, currenciesIds = filterCurrenciesIds, fields = fields)
}
/**
* The method for determining the [Mutex], which will be used by the [fetch] method.
*
* @param params request params
*
* @return if at the moment the method of [fetch] is already executed for an adjacent set of parameters
*/
private suspend fun Raise<Error>.getFetchMutex(params: RequestParams): Mutex {
return getFetchMethodMutex.withLock {
val similarJobsMutexes = fetchMutexMap.filterKeys { metadata ->
metadata.fiatCurrencyId == params.fiatCurrencyId &&
params.currenciesIds.any { it in metadata.currenciesIds }
}
val storedMutex = similarJobsMutexes.firstNotNullOfOrNull { it.value }
when {
storedMutex == null || !storedMutex.isLocked -> Mutex()
storedMutex.isLocked -> storedMutex
else -> raise(Error.CacheOperationError)
}
.also { fetchMutexMap[params] = it }
}
}
/**
* Fetch quotes by [params].
*
* It works in the scope of the transferred [mutex].
* If at the moment [Mutex] is busy, then it is working on fetching related data, then the current request will
* wait for its execution.
*
* @return [QuotesResponse]
*/
private suspend fun Raise<Error>.fetch(params: RequestParams, mutex: Mutex): QuotesResponse {
val (fiatCurrencyId, currenciesIds) = params
return mutex.withLock {
val quotes = quotesCache[fiatCurrencyId].orEmpty()
val expiredOrAbsentIds = currenciesIds.filterExpiredOrAbsent(quotes)
// We will fetch quotes only for those quotes that are absent in cache or expired
if (expiredOrAbsentIds.isNotEmpty()) {
val response = requestQuotes(
fiatCurrencyId = fiatCurrencyId,
currenciesIds = expiredOrAbsentIds.toSet(),
fields = params.fields,
)
saveQuotes(fiatCurrencyId = fiatCurrencyId, response = response)
}
fetchMutexMap.remove(params)
getCachedResult(fiatCurrencyId = fiatCurrencyId, currenciesIds = currenciesIds)
}
}
private fun Set<String>.filterExpiredOrAbsent(quotes: Set<QuoteMetadata>?): List<String> {
return filter { id ->
val quote = quotes?.firstOrNull { it.cryptoCurrencyId == id && !it.isExpired }
quote == null
}
}
private fun Raise<Error>.getCachedResult(fiatCurrencyId: String, currenciesIds: Set<String>): QuotesResponse {
val storedQuotes = quotesCache[fiatCurrencyId]
ensureNotNull(storedQuotes) {
raise(Error.CacheOperationError)
}
val quotes = catch(
block = {
currenciesIds.associateWith { currencyId ->
storedQuotes.first { it.cryptoCurrencyId == currencyId }.value
}
},
catch = { raise(Error.CacheOperationError) },
)
return QuotesResponse(quotes = quotes)
}
private suspend fun Raise<Error>.requestQuotes(
fiatCurrencyId: String,
currenciesIds: Set<String>,
fields: Set<Field>,
) = withContext(dispatchers.io) {
safeApiCallWithTimeout(
call = {
tangemTechApi.getQuotes(
currencyId = fiatCurrencyId,
coinIds = currenciesIds.joinToString(separator = ","),
fields = fields.combine(),
)
.bind()
},
onError = { raise(Error.ApiOperationError(it)) },
)
}
private fun saveQuotes(fiatCurrencyId: String, response: QuotesResponse) {
val newQuotes = response.quotes.mapTo(destination = hashSetOf()) { (currencyId, quote) ->
QuoteMetadata(
cryptoCurrencyId = currencyId,
timestamp = DateTime.now().millis,
value = quote,
)
}
val storedQuotes = quotesCache[fiatCurrencyId].orEmpty()
val storedUniqueQuotes = storedQuotes.filterNot { stored ->
newQuotes.any { stored.cryptoCurrencyId == it.cryptoCurrencyId }
}
quotesCache[fiatCurrencyId] = (storedUniqueQuotes + newQuotes).toSet()
}
data class RequestParams(
val fiatCurrencyId: String,
val currenciesIds: Set<String>,
val fields: Set<Field>,
)
data class QuoteMetadata(
val cryptoCurrencyId: String,
val timestamp: Long,
val value: QuotesResponse.Quote,
) {
val isExpired: Boolean
get() = DateTime.now().millis - timestamp > tenSecInMillis
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun getCachedQuotes() = quotesCache
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun setCachedQuotes(fiatCurrencyId: String, quotes: Set<QuoteMetadata>) {
quotesCache[fiatCurrencyId] = quotes
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun clearCache() {
quotesCache.clear()
}
private companion object {
val emptyQuotesResponse = QuotesResponse(quotes = emptyMap())
val tenSecInMillis = 10.seconds.inWholeMilliseconds
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.common.quote
import arrow.core.Either
import com.tangem.data.common.quote.utils.combine
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
/**
* Fetcher of quotes
*
[REDACTED_AUTHOR]
*
* @see <a href = "https://www.notion.so/tangem/Quotes-21b5d34eb6788038af9ccee37c7db7f9">Documentation<a/>
*/
interface QuotesFetcher {
/**
* Fetch
*
* @param fiatCurrencyId fiat currency id
* @param currenciesIds crypto currencies ids
* @param fields fields of [QuotesResponse.Quote]
*/
suspend fun fetch(
fiatCurrencyId: String,
currenciesIds: Set<String>,
fields: Set<Field>,
): Either<Error, QuotesResponse>
/**
* Fetch
*
* @param fiatCurrencyId fiat currency id
* @param currencyId crypto currencies ids
* @param field fields of [QuotesResponse.Quote]
*/
suspend fun fetch(fiatCurrencyId: String, currencyId: String, field: Field): Either<Error, QuotesResponse> {
return fetch(fiatCurrencyId = fiatCurrencyId, currenciesIds = setOf(currencyId), fields = setOf(field))
}
enum class Field(internal val value: String) {
PRICE(value = "price"),
PRICE_CHANGE_24H(value = "priceChange24h"),
PRICE_CHANGE_1W(value = "priceChange1w"),
PRICE_CHANGE_30D(value = "priceChange30d"),
ALL_PRICES(
value = setOf(PRICE, PRICE_CHANGE_24H, PRICE_CHANGE_1W, PRICE_CHANGE_30D).combine(),
),
LAST_UPDATED_AT(value = "lastUpdatedAt"),
;
}
sealed interface Error {
data object InvalidArgumentsError : Error
data object CacheOperationError : Error
data class ApiOperationError(val apiError: ApiResponseError) : Error
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,444 @@
package com.tangem.data.common.quote
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.data.common.quote.DefaultQuotesFetcher.QuoteMetadata
import com.tangem.data.common.quote.QuotesFetcher.Field
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.test.runTest
import org.joda.time.DateTime
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultQuotesFetcherTest {
private val tangemTechApi = mockk<TangemTechApi>()
private val fetcher = DefaultQuotesFetcher(
tangemTechApi = tangemTechApi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(tangemTechApi)
fetcher.clearCache()
}
@Test
fun `fetch if fiatCurrencyId is EMPTY`() = runTest {
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "",
currenciesIds = setOf("ethereum"),
fields = setOf(Field.PRICE),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesFetcher.Error.InvalidArgumentsError.left()
Truth.assertThat(actual).isEqualTo(expected)
val expectedCacheData = ConcurrentHashMap<String, Set<QuoteMetadata>>()
Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData)
coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) }
}
@Test
fun `fetch if currenciesIds is EMPTY`() = runTest {
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = emptySet(),
fields = setOf(Field.PRICE),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesResponse(quotes = emptyMap()).right()
Truth.assertThat(actual).isEqualTo(expected)
val expectedCacheData = ConcurrentHashMap<String, Set<QuoteMetadata>>()
Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData)
coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) }
}
@Test
fun `fetch if fields is EMPTY`() = runTest {
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum"),
fields = emptySet(),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesFetcher.Error.InvalidArgumentsError.left()
Truth.assertThat(actual).isEqualTo(expected)
val expectedCacheData = ConcurrentHashMap<String, Set<QuoteMetadata>>()
Truth.assertThat(actualCacheData).isEqualTo(expectedCacheData)
coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) }
}
@Test
fun `fetch if all currencies ids ARE CACHED and ARE NOT EXPIRED`() = runTest {
// Arrange
val quote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
fetcher.setCachedQuotes(
fiatCurrencyId = "usd",
quotes = setOf(
QuoteMetadata(
cryptoCurrencyId = "ethereum",
timestamp = DateTime.now().millis,
value = quote,
),
),
)
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum"),
fields = setOf(Field.PRICE),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesResponse(quotes = mapOf("ethereum" to quote)).right()
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1)
Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to quote))
coVerify(inverse = true) { tangemTechApi.getQuotes(currencyId = any(), coinIds = any(), fields = any()) }
}
@Test
fun `fetch if all currencies ids ARE CACHED and EXPIRED`() = runTest {
// Arrange
val cachedQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
val apiResponse = ApiResponse.Success(
data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)),
)
fetcher.setCachedQuotes(
fiatCurrencyId = "usd",
quotes = setOf(
QuoteMetadata(
cryptoCurrencyId = "ethereum",
timestamp = DateTime.now().millis - 10_000,
value = cachedQuote,
),
),
)
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price")
} returns apiResponse
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum"),
fields = setOf(Field.PRICE),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right()
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1)
Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to apiQuote))
coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") }
}
@Test
fun `fetch if cache contain EXPIRED and NOT EXPIRED quotes`() = runTest {
// Arrange
val cachedQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO)
val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
val apiResponse = ApiResponse.Success(
data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)),
)
fetcher.setCachedQuotes(
fiatCurrencyId = "usd",
quotes = setOf(
QuoteMetadata(
cryptoCurrencyId = "ethereum",
timestamp = DateTime.now().millis - 10_000,
value = cachedQuote,
),
QuoteMetadata(
cryptoCurrencyId = "bitcoin",
timestamp = DateTime.now().millis,
value = cachedQuote,
),
),
)
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price")
} returns apiResponse
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum", "bitcoin"),
fields = setOf(Field.PRICE),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesResponse(
quotes = mapOf("ethereum" to apiQuote, "bitcoin" to cachedQuote),
).right()
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1)
Truth.assertThat(actualCacheData["usd"].toResponseQuotes())
.isEqualTo(mapOf("ethereum" to apiQuote, "bitcoin" to cachedQuote))
coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") }
}
@Test
fun `fetch if cache DOES NOT CONTAIN fiat currency`() = runTest {
// Arrange
val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
val apiResponse = ApiResponse.Success(
data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)),
)
fetcher.setCachedQuotes(fiatCurrencyId = "eu", quotes = emptySet())
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price")
} returns apiResponse
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum"),
fields = setOf(Field.PRICE),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right()
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(2)
Truth.assertThat(actualCacheData["eu"]).isEmpty()
Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(mapOf("ethereum" to apiQuote))
coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") }
}
@Test
fun `fetch if cached quotes are ABSENT`() = runTest {
// Arrange
val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
val apiResponse = ApiResponse.Success(
data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)),
)
fetcher.setCachedQuotes(fiatCurrencyId = "usd", quotes = emptySet())
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price")
} returns apiResponse
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum"),
fields = setOf(Field.PRICE),
)
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right()
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1)
Truth.assertThat(actualCacheData["usd"].toResponseQuotes())
.isEqualTo(mapOf("ethereum" to apiQuote))
coVerify(exactly = 1) { tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price") }
}
@Test
fun `two parallel fetch if cache is empty`() = runTest {
// Arrange
val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
val usdApiResponse = ApiResponse.Success(
data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)),
)
val euApiResponse = ApiResponse.Success(
data = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote)),
)
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price")
} returns usdApiResponse
coEvery {
tangemTechApi.getQuotes(currencyId = "eu", coinIds = "bitcoin", fields = "price")
} returns euApiResponse
// Act
val actual = listOf(
async {
fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum"),
fields = setOf(Field.PRICE),
)
},
async {
fetcher.fetch(
fiatCurrencyId = "eu",
currenciesIds = setOf("bitcoin"),
fields = setOf(Field.PRICE),
)
},
)
.awaitAll()
val actual1 = actual[0]
val actual2 = actual[1]
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected1 = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)).right()
val expected2 = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote)).right()
Truth.assertThat(actual1).isEqualTo(expected1)
Truth.assertThat(actual2).isEqualTo(expected2)
Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(2)
Truth.assertThat(actualCacheData["usd"].toResponseQuotes())
.isEqualTo(mapOf("ethereum" to apiQuote))
Truth.assertThat(actualCacheData["eu"].toResponseQuotes())
.isEqualTo(mapOf("bitcoin" to apiQuote))
coVerify(exactly = 1) {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum", fields = "price")
tangemTechApi.getQuotes(currencyId = "eu", coinIds = "bitcoin", fields = "price")
}
}
@Test
fun `two parallel fetch`() = runTest {
// Arrange
val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
val firstApiResponse = ApiResponse.Success(
data = QuotesResponse(
quotes = mapOf("ethereum" to apiQuote, "solana" to apiQuote),
),
)
val secondApiResponse = ApiResponse.Success(
data = QuotesResponse(
quotes = mapOf("bitcoin" to apiQuote, "solana" to apiQuote),
),
)
fetcher.setCachedQuotes(
fiatCurrencyId = "usd",
quotes = setOf(
QuoteMetadata(
cryptoCurrencyId = "solana",
timestamp = DateTime.now().millis - 10_000,
value = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ZERO),
),
),
)
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum,solana", fields = "price")
} returns firstApiResponse
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "bitcoin", fields = "price")
} returns secondApiResponse
// Act
val actual = listOf(
async {
fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum", "solana"),
fields = setOf(Field.PRICE),
)
},
async {
fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("bitcoin", "solana"),
fields = setOf(Field.PRICE),
)
},
)
.awaitAll()
val actual1 = actual[0]
val actual2 = actual[1]
val actualCacheData = fetcher.getCachedQuotes()
// Assert
val expected1 = QuotesResponse(quotes = mapOf("ethereum" to apiQuote, "solana" to apiQuote)).right()
val expected2 = QuotesResponse(quotes = mapOf("bitcoin" to apiQuote, "solana" to apiQuote)).right()
Truth.assertThat(actual1).isEqualTo(expected1)
Truth.assertThat(actual2).isEqualTo(expected2)
Truth.assertThat(actualCacheData.keys().toList().size).isEqualTo(1)
Truth.assertThat(actualCacheData["usd"].toResponseQuotes()).isEqualTo(
mapOf("ethereum" to apiQuote, "bitcoin" to apiQuote, "solana" to apiQuote),
)
coVerifyOrder {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum,solana", fields = "price")
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "bitcoin", fields = "price")
}
}
private fun Iterable<QuoteMetadata>?.toResponseQuotes() = this!!.associate { it.cryptoCurrencyId to it.value }
}

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

@ -0,0 +1 @@
/build

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(

View file

@ -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,

View file

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

View file

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

View file

@ -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 {

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}" },
) {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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")),
),
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View file

@ -0,0 +1 @@
/build

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}" },
) {}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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