Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-01 21:31:52 +04:00
commit 3aaf9c5b0a
2074 changed files with 88068 additions and 12659 deletions

View file

@ -8,13 +8,10 @@ plugins {
android {
namespace = "com.tangem.data.account"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(projects.features.virtualAccounts.details.api) // VIRTUAL_ACCOUNTS_ENABLED
// region Project - Common
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
// endregion
@ -70,7 +67,6 @@ dependencies {
// region Test
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.turbine)
// endregion
}

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId
internal fun String.toAccountId(userWalletId: UserWalletId): AccountId {
return when {
startsWith(AccountId.PaymentAccountIdPrefix) -> AccountId.forPaymentAccount(userWalletId).right()
startsWith(AccountId.VirtualAccountIdPrefix) -> AccountId.forVirtualAccount(userWalletId).right()
else -> AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId)
}.getOrElse {
error("Unable to create AccountId from value: $this. Cause: $it")

View file

@ -25,6 +25,7 @@ import com.tangem.datasource.api.tangemTech.models.orDefault
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.withContext
@ -143,6 +144,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
apiResponse.bind().enrichByAccountId()
},
onError = { error ->
TangemLogger.e(
"pushInternal wallet=$userWalletId: PUT /accounts failed, " +
"isPreconditionFailed=${error.isNetworkError(code = Code.PRECONDITION_FAILED)}, error=$error",
)
if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) {
throw error
}

View file

@ -59,6 +59,8 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): FetchResult {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isResponseUpToDate) {
TangemLogger.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
val response = requireNotNull(savedAccountsResponse) {
@ -71,13 +73,16 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
val response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) {
val eTag = createWallet(userWalletId)
if (eTag != null) {
pushWalletAccounts(accountDTOs, eTag)
userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse)
} else {
TangemLogger.e(
"handle wallet=$userWalletId: account creation skipped, createWallet returned null eTag",
)
}
}
@ -112,10 +117,17 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
private suspend fun createWallet(userWalletId: UserWalletId): String? {
val creationResponse = walletServerBinder.bind(userWalletId)
return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) {
val isCreated = creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED
val eTag = if (isCreated) {
creationResponse.headers[ETAG_HEADER]?.firstOrNull()
} else {
null
}
if (eTag == null) {
TangemLogger.e("ETag is null for wallet: $userWalletId, isCreated: $isCreated")
}
return eTag
}
}

View file

@ -11,6 +11,7 @@ import com.tangem.domain.common.wallets.getSyncStrict
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
@ -37,6 +38,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
override val flowProducerTools: FlowProducerTools,
private val walletAccountListFlowFactory: WalletAccountListFlowFactory,
private val userWalletsListRepository: UserWalletsListRepository,
private val virtualAccountsFeatureToggles: VirtualAccountFeatureToggles,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountListProducer {
@ -51,18 +53,9 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
return walletAccountListFlowFactory.create(walletId)
.map { accountList ->
val userWallet = userWalletsListRepository.getSyncStrict(id = walletId)
val isPaymentSupported = userWallet.isPaymentAccountSupported()
logger.i(
"produce()[$walletId]: userWallet resolved (type=${userWallet::class.simpleName}), " +
"isPaymentAccountSupported=$isPaymentSupported",
)
if (isPaymentSupported) {
accountList.plus(Account.Payment(walletId)).getOrElse { throwable ->
error("Can not combine account list and payment account status: $throwable")
}
} else {
accountList
}
accountList
.addAccountIf(userWallet.isPaymentAccountSupported()) { Account.Payment(walletId) }
.addAccountIf(userWallet.isVirtualAccountSupported()) { Account.Virtual(walletId) }
}
.flowOn(dispatchers.default)
}
@ -72,6 +65,25 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
}
private fun UserWallet.isVirtualAccountSupported(): Boolean {
if (!virtualAccountsFeatureToggles.isVirtualAccountsEnabled) return false
return when (this) {
is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable
is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword
}
}
private inline fun AccountList.addAccountIf(condition: Boolean, account: () -> Account): AccountList {
return if (condition) {
plus(account()).getOrElse { throwable ->
error("Can not combine account list and special account: $throwable")
}
} else {
this
}
}
@AssistedFactory
interface Factory : SingleAccountListProducer.Factory {
override fun create(params: SingleAccountListProducer.Params): DefaultSingleAccountListProducer

View file

@ -335,7 +335,10 @@ class DefaultWalletAccountsFetcherTest {
body = SaveWalletAccountsResponse(savedAccountsResponse.accounts),
)
eTagsStore.clear(userWalletId, ETagsStore.Key.WalletAccounts)
userTokensSaver.push(userWalletId = userWalletId, response = savedAccountsResponse.toUserTokensResponse())
userTokensSaver.push(
userWalletId = userWalletId,
response = savedAccountsResponse.toUserTokensResponse(),
)
tokensMigration.migrate(userWalletId)
}
}

View file

@ -8,22 +8,27 @@ import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import app.cash.turbine.test
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultMultiAccountListProducerTest {
@ -40,6 +45,23 @@ class DefaultMultiAccountListProducerTest {
dispatchers = TestingCoroutineDispatcherProvider(),
)
private fun TestScope.createProducer(): DefaultMultiAccountListProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultMultiAccountListProducer(
params = Unit,
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
userWalletsListRepository = userWalletsListRepository,
singleAccountListSupplier = singleAccountListSupplier,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
}
private val userWalletId = UserWalletId("011")
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
@ -144,7 +166,6 @@ class DefaultMultiAccountListProducerTest {
}
}
@Disabled
@Test
fun `flow returns empty list if factory throws exception`() = runTest {
// Arrange
@ -154,12 +175,12 @@ class DefaultMultiAccountListProducerTest {
val exception = RuntimeException("Converter error")
every { singleAccountListSupplier.invoke(userWalletId) } throws exception
// Act
val actual = producer.produceWithFallback().let(::getEmittedValues)
// Assert
val expected = emptyList<AccountList>()
Truth.assertThat(actual).containsExactly(expected)
// Act / Assert: the factory throws -> retryWhen emits the empty fallback.
// Stop before the 2s retry fires so the upstream is collected exactly once.
createProducer().produceWithFallback().test {
Truth.assertThat(awaitItem()).isEqualTo(emptyList<AccountList>())
cancelAndIgnoreRemainingEvents()
}
coVerifySequence {
userWalletsListRepository.load()

View file

@ -1,13 +1,16 @@
package com.tangem.data.account.producer
import arrow.core.getOrElse
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles
import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -40,12 +43,16 @@ class DefaultSingleAccountListProducerTest {
private val userWalletsListRepository = mockk<UserWalletsListRepository> {
every { userWallets } returns MutableStateFlow<List<UserWallet>?>(value = listOf(userWallet))
}
private val virtualAccountsFeatureToggles = mockk<VirtualAccountFeatureToggles> {
every { isVirtualAccountsEnabled } returns false
}
private val producer = DefaultSingleAccountListProducer(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
walletAccountListFlowFactory = walletAccountListFlowFactory,
flowProducerTools = flowProducerTools,
userWalletsListRepository = userWalletsListRepository,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@ -72,6 +79,45 @@ class DefaultSingleAccountListProducerTest {
}
}
@Test
fun `GIVEN virtual accounts enabled WHEN produce THEN account list contains virtual account`() = runTest {
// Arrange
val supportedWallet = mockk<UserWallet.Hot> {
every { walletId } returns userWalletId
every { hotWalletId } returns mockk {
every { authType } returns HotWalletId.AuthType.Password
}
}
val userWalletsListRepository = mockk<UserWalletsListRepository> {
every { userWallets } returns MutableStateFlow<List<UserWallet>?>(value = listOf(supportedWallet))
}
val virtualAccountsFeatureToggles = mockk<VirtualAccountFeatureToggles> {
every { isVirtualAccountsEnabled } returns true
}
val producer = DefaultSingleAccountListProducer(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
walletAccountListFlowFactory = walletAccountListFlowFactory,
flowProducerTools = flowProducerTools,
userWalletsListRepository = userWalletsListRepository,
virtualAccountsFeatureToggles = virtualAccountsFeatureToggles,
dispatchers = TestingCoroutineDispatcherProvider(),
)
val accountList = AccountList.empty(userWalletId)
every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList)
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = accountList
.plus(Account.Payment(userWalletId))
.getOrElse { error("Unable to add payment account: $it") }
.plus(Account.Virtual(userWalletId))
.getOrElse { error("Unable to add virtual account: $it") }
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `flow will updated if factoryFlow is updated`() = runTest {
// Arrange

View file

@ -19,7 +19,6 @@ import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.Account.CryptoPortfolio
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName

View file

@ -2,7 +2,7 @@ package com.tangem.data.account.repository
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.domain.account.models.AccountExpandedState
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
@ -21,7 +21,7 @@ class DefaultAccountsExpandedRepositoryTest {
@Test
fun `expandedAccounts emits updated state when store changes`() = runTest {
val dataStore = MockStateDataStore<Map<String, Set<AccountsExpandedDTO>>>(
default = emptyMap()
default = emptyMap(),
)
val repository = DefaultAccountsExpandedRepository(dataStore)
@ -37,9 +37,9 @@ class DefaultAccountsExpandedRepositoryTest {
walletId.stringValue to setOf(
AccountsExpandedDTO(
accountId = mainAccountId.value,
isExpanded = true
)
)
isExpanded = true,
),
),
)
}
@ -60,14 +60,14 @@ class DefaultAccountsExpandedRepositoryTest {
@Test
fun `expandedAccounts emits when update is called`() = runTest {
val dataStore = MockStateDataStore<Map<String, Set<AccountsExpandedDTO>>>(
default = emptyMap()
default = emptyMap(),
)
val repository = DefaultAccountsExpandedRepository(dataStore)
val state = AccountExpandedState(
accountId = mainAccountId,
isExpanded = true
isExpanded = true,
)
repository.expandedAccounts.test {
@ -94,9 +94,9 @@ class DefaultAccountsExpandedRepositoryTest {
mapOf(
walletId.stringValue to setOf(
AccountsExpandedDTO(mainAccountId.value, true),
AccountsExpandedDTO(secondAccountId.value, false)
)
)
AccountsExpandedDTO(secondAccountId.value, false),
),
),
)
val repository = DefaultAccountsExpandedRepository(dataStore)
@ -109,7 +109,7 @@ class DefaultAccountsExpandedRepositoryTest {
// when
repository.syncStore(
walletId = walletId,
existAccounts = setOf(mainAccountId) // without secondAccountId
existAccounts = setOf(mainAccountId), // without secondAccountId
)
// then

View file

@ -3,7 +3,7 @@ package com.tangem.data.account.store
import android.content.Context
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.mockk

View file

@ -1,7 +1,7 @@
package com.tangem.data.account.token
import arrow.core.right
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.AccountsResponseStore

View file

@ -1,7 +1,6 @@
package com.tangem.domain.card.common
import org.junit.Assert
import org.junit.Test
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class TwinsHelperTest {
@ -18,7 +17,7 @@ class TwinsHelperTest {
@Test
fun `twins compatibility pack 1 success`() {
Assert.assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[0], pack1Twins[1]))
assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[0], pack1Twins[1]))
assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[1], pack1Twins[0]))
}

View file

@ -78,6 +78,23 @@ internal class DefaultAssetsDiscoveryRepository(
assetsDiscoveryStore.clear()
}
override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
if (currencies.isEmpty()) return
val appliedIds = currencies.mapTo(hashSetOf(), CryptoCurrency::id)
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId)
assetsDiscoveryStore.removeMatching { token ->
val currency = responseCryptoCurrenciesFactory.createCurrency(
responseToken = token,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
currency != null && currency.id in appliedIds
}
}
override suspend fun clearPendingFlag(userWalletId: UserWalletId) {
setPendingFlag(userWalletId, value = false)
}

View file

@ -8,5 +8,7 @@ interface AssetsDiscoveryStore {
suspend fun append(tokens: List<UserTokensResponse.Token>)
suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean)
suspend fun clear()
}

View file

@ -18,6 +18,12 @@ internal class DefaultAssetsDiscoveryStore(
}
}
override suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean) {
persistenceStore.updateData { existing ->
existing.filterNot(predicate)
}
}
override suspend fun clear() {
persistenceStore.updateData { emptyList() }
}

View file

@ -37,7 +37,7 @@ dependencies {
/* Tests */
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testImplementation(deps.test.mockk)
testImplementation(deps.test.turbine)
testImplementation(deps.test.truth)

View file

@ -8,7 +8,7 @@ import com.domain.blockaid.models.transaction.simultation.ApproveInfo
import com.domain.blockaid.models.transaction.simultation.SimulationData
import com.google.common.truth.Truth
import com.tangem.datasource.api.common.blockaid.models.response.*
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class BlockAidMapperTest {

View file

@ -17,8 +17,8 @@ import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class DefaultBlockAidRepositoryTest {
@ -32,7 +32,7 @@ class DefaultBlockAidRepositoryTest {
private val dispatchers = TestingCoroutineDispatcherProvider()
@Before
@BeforeEach
fun setup() {
MockKAnnotations.init(this)
repository = DefaultBlockAidRepository(api, dispatchers, mapper)

View file

@ -30,4 +30,8 @@ dependencies {
implementation(projects.domain.card)
implementation(projects.domain.models)
// region Tests
testImplementation(projects.test.core)
// end
}

View file

@ -32,33 +32,9 @@ internal class DefaultCardRepository(
appPreferencesStore.editUsedCards(cardId) { it.copy(isActivationStarted = true) }
}
override suspend fun finishCardActivation(cardId: String) {
override suspend fun finishCardActivation(cardId: String, hasBackupError: Boolean) {
appPreferencesStore.editUsedCards(cardId) {
it.copy(isActivationStarted = true, isActivationFinished = true)
}
}
override suspend fun finishCardsActivation(cardIds: List<String>) {
appPreferencesStore.editData { mutablePreferences ->
val usedCards = mutablePreferences.getUsedCards()
val newCards = cardIds.mapNotNull { newCardId ->
if (usedCards.none { it.cardId == newCardId }) {
createDefaultUsedCardInfo(cardId = newCardId)
} else {
null
}
}
val updatedUsedCards = (usedCards + newCards).map { card ->
if (cardIds.contains(card.cardId)) {
card.copy(isActivationStarted = true, isActivationFinished = true)
} else {
card
}
}
mutablePreferences.setObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = updatedUsedCards)
it.copy(isActivationStarted = true, isActivationFinished = true, hasBackupError = hasBackupError)
}
}
@ -76,6 +52,10 @@ internal class DefaultCardRepository(
return card.isActivationStarted && !card.isActivationFinished
}
override suspend fun hasBackupError(cardId: String): Boolean {
return getUsedCardSync(cardId)?.hasBackupError == true
}
override suspend fun isTangemTOSAccepted(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.IS_TANGEM_TOS_ACCEPTED_KEY, default = false)
}

View file

@ -0,0 +1,287 @@
package com.tangem.data.card
import androidx.datastore.preferences.core.emptyPreferences
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Moshi
import com.tangem.datasource.local.card.UsedCardInfo
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectListSync
import com.tangem.datasource.local.preferences.utils.storeObjectList
import com.tangem.test.core.ProvideTestModels
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
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
/**
* Tests for [DefaultCardRepository].
*
* Uses a real [AppPreferencesStore] backed by an in-memory [MockStateDataStore] and a real [Moshi]
* instance, so the JSON round-trip through preferences is exercised end-to-end.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultCardRepositoryTest {
// Only the in-memory store's content is mutable, so it is the single thing reset per test.
private val dataStore = MockStateDataStore(default = emptyPreferences())
private val appPreferencesStore = AppPreferencesStore(
moshi = Moshi.Builder().build(),
dispatchers = TestingCoroutineDispatcherProvider(),
preferencesDataStore = dataStore,
)
private val repository = DefaultCardRepository(appPreferencesStore)
@BeforeEach
fun resetStore() {
runBlocking { dataStore.updateData { emptyPreferences() } }
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class WasCardScanned {
@Test
fun `GIVEN card present WHEN wasCardScanned THEN emits true`() = runTest {
// Arrange
seedCards(UsedCardInfo(cardId = CARD_ID))
// Act
val result = repository.wasCardScanned(CARD_ID).first()
// Assert
assertThat(result).isTrue()
}
@Test
fun `GIVEN card absent WHEN wasCardScanned THEN emits false`() = runTest {
// Act
val result = repository.wasCardScanned(CARD_ID).first()
// Assert
assertThat(result).isFalse()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class SetCardWasScanned {
@Test
fun `GIVEN empty store WHEN setCardWasScanned THEN creates entry with isScanned true`() = runTest {
// Act
repository.setCardWasScanned(CARD_ID)
// Assert
assertThat(storedCards()).containsExactly(UsedCardInfo(cardId = CARD_ID, isScanned = true))
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class StartCardActivation {
@Test
fun `GIVEN empty store WHEN startCardActivation THEN creates entry with isActivationStarted true`() = runTest {
// Act
repository.startCardActivation(CARD_ID)
// Assert
assertThat(storedCards()).containsExactly(UsedCardInfo(cardId = CARD_ID, isActivationStarted = true))
}
@Test
fun `GIVEN other cards present WHEN editing one card THEN others are preserved`() = runTest {
// Arrange
val other = UsedCardInfo(cardId = OTHER_CARD_ID, isScanned = true)
seedCards(other)
// Act
repository.startCardActivation(CARD_ID)
// Assert
assertThat(storedCards()).containsExactly(
other,
UsedCardInfo(cardId = CARD_ID, isActivationStarted = true),
)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class FinishCardActivation {
@Test
fun `GIVEN empty store WHEN finishCardActivation with backup error THEN entry marked finished with error`() =
runTest {
// Act
repository.finishCardActivation(cardId = CARD_ID, hasBackupError = true)
// Assert
assertThat(storedCards()).containsExactly(
UsedCardInfo(
cardId = CARD_ID,
isActivationStarted = true,
isActivationFinished = true,
hasBackupError = true,
),
)
}
@Test
fun `GIVEN empty store WHEN finishCardActivation without backup error THEN entry marked finished without error`() =
runTest {
// Act
repository.finishCardActivation(cardId = CARD_ID, hasBackupError = false)
// Assert
assertThat(storedCards()).containsExactly(
UsedCardInfo(
cardId = CARD_ID,
isActivationStarted = true,
isActivationFinished = true,
hasBackupError = false,
),
)
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsActivationStarted {
@Test
fun `GIVEN activation started WHEN isActivationStarted THEN true`() = runTest {
// Arrange
seedCards(UsedCardInfo(cardId = CARD_ID, isActivationStarted = true))
// Act & Assert
assertThat(repository.isActivationStarted(CARD_ID)).isTrue()
}
@Test
fun `GIVEN card absent WHEN isActivationStarted THEN false`() = runTest {
assertThat(repository.isActivationStarted(CARD_ID)).isFalse()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsActivationFinished {
@Test
fun `GIVEN activation finished WHEN isActivationFinished THEN true`() = runTest {
// Arrange
seedCards(UsedCardInfo(cardId = CARD_ID, isActivationFinished = true))
// Act & Assert
assertThat(repository.isActivationFinished(CARD_ID)).isTrue()
}
@Test
fun `GIVEN card absent WHEN isActivationFinished THEN false`() = runTest {
assertThat(repository.isActivationFinished(CARD_ID)).isFalse()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class IsActivationInProgress {
@ParameterizedTest
@ProvideTestModels
fun isActivationInProgress(model: ActivationInProgressModel) = runTest {
// Arrange
model.stored?.let { seedCards(it) }
// Act
val result = repository.isActivationInProgress(CARD_ID)
// Assert
assertThat(result).isEqualTo(model.expected)
}
private fun provideTestModels() = listOf(
ActivationInProgressModel(stored = null, expected = false),
ActivationInProgressModel(
stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = false, isActivationFinished = false),
expected = false,
),
ActivationInProgressModel(
stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = true, isActivationFinished = false),
expected = true,
),
ActivationInProgressModel(
stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = true, isActivationFinished = true),
expected = false,
),
ActivationInProgressModel(
stored = UsedCardInfo(cardId = CARD_ID, isActivationStarted = false, isActivationFinished = true),
expected = false,
),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class HasBackupError {
@Test
fun `GIVEN backup error WHEN hasBackupError THEN true`() = runTest {
// Arrange
seedCards(UsedCardInfo(cardId = CARD_ID, hasBackupError = true))
// Act & Assert
assertThat(repository.hasBackupError(CARD_ID)).isTrue()
}
@Test
fun `GIVEN no backup error WHEN hasBackupError THEN false`() = runTest {
// Arrange
seedCards(UsedCardInfo(cardId = CARD_ID, hasBackupError = false))
// Act & Assert
assertThat(repository.hasBackupError(CARD_ID)).isFalse()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class TangemTos {
@Test
fun `GIVEN nothing stored WHEN isTangemTOSAccepted THEN false by default`() = runTest {
assertThat(repository.isTangemTOSAccepted()).isFalse()
}
@Test
fun `GIVEN TOS accepted WHEN isTangemTOSAccepted THEN true`() = runTest {
// Arrange
repository.acceptTangemTOS()
// Act & Assert
assertThat(repository.isTangemTOSAccepted()).isTrue()
}
}
private suspend fun seedCards(vararg cards: UsedCardInfo) {
appPreferencesStore.storeObjectList(key = PreferencesKeys.USED_CARDS_INFO_KEY, value = cards.toList())
}
private suspend fun storedCards(): List<UsedCardInfo> {
return appPreferencesStore.getObjectListSync(key = PreferencesKeys.USED_CARDS_INFO_KEY)
}
internal data class ActivationInProgressModel(val stored: UsedCardInfo?, val expected: Boolean)
private companion object {
const val CARD_ID = "card-1"
const val OTHER_CARD_ID = "card-2"
}
}

View file

@ -8,11 +8,6 @@ plugins {
android {
namespace = "com.tangem.data.common"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Core */
implementation(projects.core.datasource)
@ -26,6 +21,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.express.models)
implementation(projects.domain.networks)
implementation(projects.domain.walletManager)
implementation(projects.domain.wallets)
@ -49,6 +45,5 @@ dependencies {
/* Test */
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.moshi)
}

View file

@ -0,0 +1,30 @@
package com.tangem.data.common.converter
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.utils.converter.Converter
/**
* Maps a persisted [ExpressProviderEntity] into the domain [ExpressProvider].
*/
class ExpressProviderConverter : Converter<ExpressProviderEntity, ExpressProvider> {
override fun convert(value: ExpressProviderEntity): ExpressProvider {
return ExpressProvider(
providerId = value.id,
rateTypes = emptyList(),
name = value.name,
type = value.type.toExpressProviderType(),
imageLarge = value.imageLarge,
termsOfUse = value.termsOfUse,
privacyPolicy = value.privacyPolicy,
isRecommended = value.isRecommended,
slippage = value.slippage?.toBigDecimalOrNull(),
isExchangeOnlyWithinSingleAddress = value.isExchangeOnlyWithinSingleAddress,
isExtraIdSupported = value.isExtraIdSupported,
)
}
private fun String.toExpressProviderType(): ExpressProviderType = ExpressProviderType.valueOf(this)
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.common.wallet
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
@ -9,6 +10,7 @@ import com.tangem.domain.common.wallets.getSyncOrNull
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
internal class DefaultWalletServerBinder(
@ -19,7 +21,12 @@ internal class DefaultWalletServerBinder(
) : WalletServerBinder {
override suspend fun bind(userWalletId: UserWalletId): ApiResponse<Unit>? {
val userWallet = userWalletsListRepository.getSyncOrNull(id = userWalletId) ?: return null
val userWallet = userWalletsListRepository.getSyncOrNull(id = userWalletId)
if (userWallet == null) {
TangemLogger.e("bind wallet=$userWalletId: user wallet not found locally, skipping createWallet call")
return null
}
return bind(userWallet)
}
@ -31,6 +38,12 @@ internal class DefaultWalletServerBinder(
tangemTechApi.createWallet(
body = WalletIdBodyConverter.convert(userWallet, conversionData),
)
}.also { response ->
val eTag = response.headers[ETAG_HEADER]?.firstOrNull()
TangemLogger.i(
"bind wallet=${userWallet.walletId}: createWallet code=${(response as? ApiResponse.Success)?.code}, " +
"hasETag=${eTag != null}, eTagNotEmpty=${!eTag.isNullOrEmpty()}",
)
}
}
}

View file

@ -8,11 +8,6 @@ plugins {
android {
namespace = "com.tangem.data.dynamicaddresses"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.configToggles)
@ -44,7 +39,6 @@ dependencies {
// endregion
// region Testing
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.test.core)
// endregion
}

View file

@ -25,6 +25,7 @@ dependencies {
implementation(projects.domain.express.models)
implementation(projects.domain.express)
implementation(projects.domain.wallets.models)
implementation(projects.domain.txhistory)
api(projects.domain.models)
/** Other */

View file

@ -5,19 +5,24 @@ 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.converter.toEntity
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressProviderType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.filterIf
import com.tangem.utils.logging.TangemLogger
internal class DefaultExpressRepository(
private val tangemExpressApi: TangemExpressApi,
private val expressHistoryDao: ExpressHistoryDao,
private val appPreferencesStore: AppPreferencesStore,
private val dispatchers: CoroutineDispatcherProvider,
private val txHistoryFeatureToggles: TxHistoryFeatureToggles,
) : ExpressRepository {
override suspend fun getProviders(
@ -26,13 +31,19 @@ internal class DefaultExpressRepository(
): List<ExpressProvider> = with(dispatchers.io) {
safeApiCall(
call = {
tangemExpressApi.getProviders(
val providers = tangemExpressApi.getProviders(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
).getOrThrow().map(ExpressProviderConverter()::convert)
).getOrThrow()
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
expressHistoryDao.upsertProviders(providers.map { it.toEntity() })
}
providers.map(ExpressProviderConverter()::convert)
.filterIf(filterProviderTypes.isNotEmpty()) { it.type in filterProviderTypes }
},
onError = { error ->

View file

@ -9,9 +9,11 @@ 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.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.domain.express.ExpressErrorResolver
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -36,13 +38,17 @@ internal object ExpressDataModule {
@Singleton
fun provideExpressRepository(
tangemExpressApi: TangemExpressApi,
expressHistoryDao: ExpressHistoryDao,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
txHistoryFeatureToggles: TxHistoryFeatureToggles,
): ExpressRepository {
return DefaultExpressRepository(
tangemExpressApi = tangemExpressApi,
expressHistoryDao = expressHistoryDao,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
txHistoryFeatureToggles = txHistoryFeatureToggles,
)
}

View file

@ -8,11 +8,6 @@ plugins {
android {
namespace = "com.tangem.data.networks"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
@ -49,7 +44,6 @@ dependencies {
// endregion
// region Tests
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(tangemDeps.blockchain)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)

View file

@ -40,7 +40,12 @@ internal class DefaultMultiNetworkStatusFetcherTest {
@BeforeEach
fun resetMocks() {
clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher, dynamicAddressesInitializer)
clearMocks(
networksStatusesStore,
cardCryptoCurrencyFactory,
commonNetworkStatusFetcher,
dynamicAddressesInitializer,
)
// No dynamic addresses restore by default
coEvery { dynamicAddressesInitializer.getXpubs(any(), any()) } returns emptyMap()
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.networks.multi
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
@ -11,23 +12,28 @@ import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.toSimple
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncOrNull
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiNetworkStatusProducerTest {
@ -48,6 +54,24 @@ internal class DefaultMultiNetworkStatusProducerTest {
flowProducerTools = flowProducerTools,
)
private fun TestScope.createProducer(): DefaultMultiNetworkStatusProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultMultiNetworkStatusProducer(
params = params,
networksStatusesStore = networksStatusesStore,
userWalletsListRepository = userWalletsListRepository,
networkFactory = networkFactory,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
)
}
@BeforeEach
fun resetMocks() {
clearMocks(networksStatusesStore, userWalletsListRepository, networkFactory)
@ -66,7 +90,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
val networksStatusesFlow = flowOf(simpleStatuses)
every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
@ -132,7 +156,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// region every
every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every {
@ -235,7 +259,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// region every
every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
@ -294,7 +318,6 @@ internal class DefaultMultiNetworkStatusProducerTest {
Truth.assertThat(actual2.first()).isEqualTo(expected2)
}
@Disabled
@Test
fun `flow throws exception`() = runTest {
// Arrange
@ -319,7 +342,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
// region every
every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
every {
@ -338,30 +361,26 @@ internal class DefaultMultiNetworkStatusProducerTest {
} returns statuses.last().network
// endregion
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
// Act 1 (fallback)
val actual1 = getEmittedValues(flow = producerFlow)
producerFlow.test {
// first collection throws -> retryWhen emits the empty fallback, then waits 2s
Truth.assertThat(awaitItem()).isEqualTo(emptySet<NetworkStatus>())
// Assert
val expected1 = emptySet<NetworkStatus>()
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verify(inverse = true) {
networkFactory.create(networkId = any(), derivationPath = any(), userWallet = any())
}
verifyOrder(inverse = true) {
userWalletsListRepository.getSyncOrNull(any())
networkFactory.create(networkId = any(), derivationPath = any(), userWallet = any())
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
Truth.assertThat(awaitItem()).isEqualTo(statuses)
cancelAndIgnoreRemainingEvents()
}
// Act 2 (emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = statuses
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
verifyOrder {
userWalletsListRepository.userWallets
networkFactory.create(
@ -407,7 +426,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
val networksStatusesFlow = flowOf(simpleStatuses)
every { networksStatusesStore.get(params.userWalletId) } returns networksStatusesFlow
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsListRepository.userWallets } returns userWalletsFlow
coEvery { networkFactory.create(networkId = any(), any(), any()) } returns null

View file

@ -1,26 +1,33 @@
package com.tangem.data.networks.single
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleNetworkStatusProducerTest {
private val params = SingleNetworkStatusProducer.Params(
@ -29,15 +36,22 @@ internal class DefaultSingleNetworkStatusProducerTest {
)
private val multiNetworkStatusSupplier = mockk<MultiNetworkStatusSupplier>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val flowProducerTools: FlowProducerTools = mockk()
private val producer = DefaultSingleNetworkStatusProducer(
params = params,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
dispatchers = dispatchers,
flowProducerTools = flowProducerTools,
)
private fun TestScope.createProducer(): DefaultSingleNetworkStatusProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultSingleNetworkStatusProducer(
params = params,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
)
}
@Test
fun `test that flow is mapped for network from params`() = runTest {
@ -52,7 +66,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produce()
val actual = createProducer().produce()
verify { multiNetworkStatusSupplier(multiParams) }
@ -69,27 +83,21 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
actual.test {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null))
expected.emit(value = setOf(updatedStatus))
Truth.assertThat(awaitItem()).isEqualTo(updatedStatus)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null))
expected.emit(value = setOf(updatedStatus))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -99,26 +107,21 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
actual.test {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
// same status again -> filtered out by distinctUntilChanged
expected.emit(value = setOf(status))
expectNoEvents()
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
expected.emit(value = setOf(status))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -139,21 +142,24 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
val values1 = getEmittedValues(flow = actual)
actual.test {
// first collection throws -> retryWhen emits the fallback, then waits 2s before retrying
val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network)
Truth.assertThat(awaitItem()).isEqualTo(fallbackStatus)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
innerFlow.emit(value = true)
Truth.assertThat(awaitItem()).isEqualTo(status)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -165,7 +171,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produce()
val actual = createProducer().produce()
verify { multiNetworkStatusSupplier(multiParams) }

View file

@ -2,8 +2,8 @@ package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
@ -13,7 +13,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -2,8 +2,8 @@ package com.tangem.data.networks.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.data.networks.toDataModel
@ -15,7 +15,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.data.networks.toDataModel
@ -11,18 +11,16 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@RunWith(Parameterized::class)
internal class ParameterizedStoreStatusTest(private val model: Model) {
internal class ParameterizedStoreStatusTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithSimpleStatus>()
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
@ -34,8 +32,9 @@ internal class ParameterizedStoreStatusTest(private val model: Model) {
scope = TestAppCoroutineScope(),
)
@Test
fun `test store success`() = runTest {
@ParameterizedTest
@ProvideTestModels
fun `test store success`(model: Model) = runTest {
val actual = runCatching { store.storeStatus(userWalletId = userWalletId, status = model.status) }
Truth.assertThat(actual.isSuccess).isEqualTo(model.isSuccess)
@ -55,8 +54,7 @@ internal class ParameterizedStoreStatusTest(private val model: Model) {
val userWalletId = UserWalletId(stringValue = "011")
@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Model> {
fun provideTestModels(): Collection<Model> {
return listOf(
// region any network statuses with StatusSource.ACTUAL
MockNetworkStatusFactory.createVerified(source = StatusSource.ACTUAL).let { status ->

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.data.networks.toDataModel
@ -11,18 +11,16 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@RunWith(Parameterized::class)
internal class ParameterizedStoreSuccessTest(private val model: Model) {
internal class ParameterizedStoreSuccessTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithSimpleStatus>()
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
@ -34,8 +32,9 @@ internal class ParameterizedStoreSuccessTest(private val model: Model) {
scope = TestAppCoroutineScope(),
)
@Test
fun `test store success`() = runTest {
@ParameterizedTest
@ProvideTestModels
fun `test store success`(model: Model) = runTest {
val actual = runCatching { store.storeSuccess(userWalletId = userWalletId, status = model.status) }
Truth.assertThat(actual.isSuccess).isEqualTo(model.isSuccess)
@ -55,8 +54,7 @@ internal class ParameterizedStoreSuccessTest(private val model: Model) {
val userWalletId = UserWalletId(stringValue = "011")
@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Model> {
fun provideTestModels(): Collection<Model> {
return listOf(
// region any network statuses with StatusSource.ACTUAL
MockNetworkStatusFactory.createVerified(source = StatusSource.ACTUAL).let { status ->

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.data.networks.toDataModel
@ -10,18 +10,16 @@ import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.ProvideTestModels
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@RunWith(Parameterized::class)
internal class ParameterizedStoreTest(private val model: Model) {
internal class ParameterizedStoreTest {
private val runtimeStore = RuntimeSharedStore<WalletIdWithSimpleStatus>()
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
@ -33,8 +31,9 @@ internal class ParameterizedStoreTest(private val model: Model) {
scope = TestAppCoroutineScope(),
)
@Test
fun `test store method`() = runTest {
@ParameterizedTest
@ProvideTestModels
fun `test store method`(model: Model) = runTest {
store.store(userWalletId = userWalletId, status = model.status)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(model.runtimeExpected)
@ -52,8 +51,7 @@ internal class ParameterizedStoreTest(private val model: Model) {
val userWalletId = UserWalletId(stringValue = "011")
@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Model> {
fun provideTestModels(): Collection<Model> {
return listOf(
MockNetworkStatusFactory.createVerified().let { status ->
Model(

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.toSimple
@ -14,7 +14,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.toSimple
@ -14,7 +14,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -27,7 +27,6 @@ internal class StoreAdaptiveThrottleTest {
val upstream = MutableSharedFlow<Set<Int>>()
upstream.adaptiveThrottle().test {
upstream.emit(setOf(1, 2))
assertThat(awaitItem()).isEqualTo(setOf(1, 2))
@ -44,7 +43,6 @@ internal class StoreAdaptiveThrottleTest {
val upstream = MutableSharedFlow<Set<Int>>()
upstream.adaptiveThrottle().test {
upstream.emit(setOf(1, 2))
awaitItem()
@ -64,7 +62,6 @@ internal class StoreAdaptiveThrottleTest {
val upstream = MutableSharedFlow<Set<Int>>()
upstream.adaptiveThrottle().test {
upstream.emit(setOf(1, 2))
awaitItem()
@ -88,7 +85,6 @@ internal class StoreAdaptiveThrottleTest {
val upstream = MutableSharedFlow<Set<Int>>()
upstream.adaptiveThrottle().test {
upstream.emit(setOf(1, 2))
awaitItem()

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.toDataModel
@ -14,7 +14,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.toDataModel
@ -13,7 +13,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.toDataModel
@ -13,7 +13,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -1,8 +1,8 @@
package com.tangem.data.networks.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
@ -17,7 +17,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -12,21 +12,20 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.network.NetworkStatus.Amount
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import com.tangem.domain.models.network.TxInfo
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
@RunWith(Parameterized::class)
internal class NetworkStatusFactoryTest(private val model: Model) {
internal class NetworkStatusFactoryTest {
@Test
fun test() {
@ParameterizedTest
@ProvideTestModels
fun test(model: Model) {
val actual = runCatching {
NetworkStatusFactory.create(
network = model.network,
@ -40,8 +39,9 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
Truth.assertThat(actual).isEqualTo(model.expected)
}
.onFailure {
Truth.assertThat(actual.exceptionOrNull()).isInstanceOf(it::class.java)
Truth.assertThat(actual.exceptionOrNull()).hasMessageThat().isEqualTo(it.message)
val expectedError = model.expected.exceptionOrNull()
Truth.assertThat(it).isInstanceOf(expectedError!!::class.java)
Truth.assertThat(it).hasMessageThat().isEqualTo(expectedError.message)
}
}
@ -56,7 +56,11 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
val selectedAddressThrowable = IllegalArgumentException("Selected address must not be null")
val currencies = with(MockCryptoCurrencyFactory()) { setOf(ethereum, createToken(Blockchain.Ethereum)) }
val currencies = with(MockCryptoCurrencyFactory()) {
// token id/contractAddress aligned with the amounts supplied by
// MockUpdateWalletManagerResultFactory.createVerifiedWith[Supplied]Token()
setOf(ethereum, createToken(Blockchain.Ethereum, id = "token", contractAddress = "0xTokenAddress"))
}
val txInfo = TxInfo(
txHash = "erroribus",
@ -75,8 +79,7 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
val updateWalletManagerResultFactory = MockUpdateWalletManagerResultFactory()
@JvmStatic
@Parameterized.Parameters
fun data(): Collection<Model> = listOf(
fun provideTestModels(): Collection<Model> = listOf(
// region MissedDerivation
createSuccess(
result = UpdateWalletManagerResult.MissedDerivation,
@ -160,7 +163,7 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
type = NetworkAddress.Address.Type.Primary,
),
),
amountToCreateAccount = BigDecimal.ZERO,
amountToCreateAccount = BigDecimal.ONE,
errorMessage = "",
source = StatusSource.ACTUAL,
),
@ -223,7 +226,10 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
currencies.last().id to setOf(),
),
source = StatusSource.ACTUAL,
yieldSupplyStatuses = mapOf(),
yieldSupplyStatuses = mapOf(
currencies.first().id to null,
currencies.last().id to null,
),
),
),
createSuccess(
@ -237,15 +243,18 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
),
),
amounts = mapOf(
currencies.first().id to Amount.Loaded(BigDecimal.ONE),
currencies.last().id to Amount.NotFound,
currencies.first().id to Amount.NotFound,
currencies.last().id to Amount.Loaded(BigDecimal.ONE),
),
pendingTransactions = mapOf(
currencies.first().id to setOf(txInfo),
currencies.last().id to setOf(txInfo),
currencies.last().id to setOf(),
),
source = StatusSource.ACTUAL,
yieldSupplyStatuses = mapOf(),
yieldSupplyStatuses = mapOf(
currencies.first().id to null,
currencies.last().id to null,
),
),
),
createSuccess(
@ -259,18 +268,19 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
),
),
amounts = mapOf(
currencies.first().id to Amount.Loaded(BigDecimal.ONE),
currencies.last().id to Amount.NotFound,
currencies.first().id to Amount.NotFound,
currencies.last().id to Amount.Loaded(BigDecimal.ONE),
),
pendingTransactions = mapOf(
currencies.first().id to setOf(txInfo),
currencies.last().id to setOf(txInfo),
currencies.last().id to setOf(),
),
source = StatusSource.ACTUAL,
yieldSupplyStatuses = mapOf(
currencies.first().id to YieldSupplyStatus(
isActive = false,
isInitialized = false,
currencies.first().id to null,
currencies.last().id to YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = false,
effectiveProtocolBalance = BigDecimal.ONE,
),

View file

@ -8,11 +8,6 @@ plugins {
android {
namespace = "com.tangem.data.news"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
@ -44,7 +39,6 @@ dependencies {
// 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)

View file

@ -11,11 +11,6 @@ plugins {
android {
namespace = "com.tangem.data.nft"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project - Data */
@ -60,5 +55,4 @@ dependencies {
testImplementation(projects.test.core)
testImplementation(projects.common.test)
testRuntimeOnly(deps.test.junit5.engine)
}

View file

@ -42,7 +42,7 @@ dependencies {
// endregion
// region tests
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)

View file

@ -9,7 +9,7 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
import androidx.datastore.preferences.core.Preferences
import com.squareup.moshi.Moshi
import androidx.datastore.core.DataStore

View file

@ -26,7 +26,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
class DefaultPushNotificationsRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()

View file

@ -31,6 +31,7 @@ dependencies {
implementation(projects.domain.appTheme.models)
implementation(projects.domain.models)
implementation(projects.domain.express.models)
implementation(projects.domain.txhistory)
// region DI

View file

@ -24,17 +24,19 @@ import com.tangem.datasource.api.onramp.models.response.model.OnrampPairDTO
import com.tangem.datasource.api.onramp.models.response.model.PaymentMethodDTO
import com.tangem.datasource.crypto.DataSignatureVerifier
import com.tangem.datasource.exchangeservice.swap.ExpressUtils
import com.tangem.datasource.local.converter.toEntity
import com.tangem.datasource.local.onramp.countries.OnrampCountriesStore
import com.tangem.datasource.local.onramp.country.OnrampCurrentCountryByIPStore
import com.tangem.datasource.local.onramp.currencies.OnrampCurrenciesStore
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.onramp.country.OnrampCurrentCountryByIPStore
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.preferences.utils.storeObject
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
@ -46,6 +48,7 @@ import com.tangem.domain.onramp.model.error.OnrampPairsError
import com.tangem.domain.onramp.model.error.OnrampRedirectError
import com.tangem.domain.onramp.repositories.OnrampRepository
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
@ -72,12 +75,14 @@ internal class DefaultOnrampRepository(
private val currenciesStore: OnrampCurrenciesStore,
private val walletManagersFacade: WalletManagersFacade,
private val dataSignatureVerifier: DataSignatureVerifier,
private val expressHistoryDao: ExpressHistoryDao,
private val txHistoryFeatureToggles: TxHistoryFeatureToggles,
moshi: Moshi,
) : OnrampRepository {
private val currencyConverter = CurrencyConverter()
private val countryConverter = CountryConverter(currencyConverter)
private val statusConverter = StatusConverter()
private val statusConverter = StatusConverter(moshi)
private val paymentMethodsConverter = PaymentMethodConverter()
private val onrampDataAdapter = moshi.adapter(OnrampDataJson::class.java)
private val onrampErrorAdapter = moshi.adapter(ExpressErrorResponse::class.java)
@ -162,6 +167,10 @@ internal class DefaultOnrampRepository(
)
.getOrThrow()
if (txHistoryFeatureToggles.isNewTxHistoryEnabled) {
expressHistoryDao.upsertOnramps(listOf(response.toEntity(ownerAddress = response.payoutAddress)))
}
statusConverter.convert(response)
}

View file

@ -1,16 +1,21 @@
package com.tangem.data.onramp.converters
import com.tangem.datasource.api.onramp.models.response.OnrampStatusResponse
import com.squareup.moshi.Moshi
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.api.onramp.models.response.Status
import com.tangem.domain.onramp.model.OnrampStatus
import com.tangem.utils.converter.Converter
internal class StatusConverter : Converter<OnrampStatusResponse, OnrampStatus> {
override fun convert(value: OnrampStatusResponse): OnrampStatus {
internal class StatusConverter(moshi: Moshi) : Converter<OnrampItemResponse, OnrampStatus> {
private val responseStatusAdapter = moshi.adapter(Status::class.java)
override fun convert(value: OnrampItemResponse): OnrampStatus {
return OnrampStatus(
txId = value.txId,
providerId = value.providerId,
payoutAddress = value.payoutAddress,
status = OnrampStatus.Status.valueOf(value.status.name),
status = OnrampStatus.Status.valueOf(value.status.toResponseStatus().name),
failReason = value.failReason,
externalTxId = value.externalTxId,
externalTxUrl = value.externalTxUrl,
@ -20,11 +25,14 @@ internal class StatusConverter : Converter<OnrampStatusResponse, OnrampStatus> {
fromAmount = value.fromAmount,
toContractAddress = value.toContractAddress,
toNetwork = value.toNetwork,
toDecimals = value.toDecimals,
toDecimals = value.toDecimals.toString(),
toAmount = value.toAmount,
toActualAmount = value.toActualAmount,
paymentMethod = value.paymentMethod,
countryCode = value.countryCode,
)
}
private fun String.toResponseStatus(): Status = responseStatusAdapter.fromJsonValue(this)
?: error("Unknown onramp status: $this")
}

View file

@ -26,9 +26,11 @@ import com.tangem.datasource.local.onramp.paymentmethods.OnrampPaymentMethodsSto
import com.tangem.datasource.local.onramp.quotes.OnrampQuotesStore
import com.tangem.datasource.local.onramp.country.OnrampCurrentCountryByIPStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.domain.onramp.repositories.*
import com.tangem.domain.txhistory.TxHistoryFeatureToggles
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -56,6 +58,8 @@ internal object OnrampDataModule {
walletManagersFacade: WalletManagersFacade,
dataSignatureVerifier: DataSignatureVerifier,
onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
expressHistoryDao: ExpressHistoryDao,
txHistoryFeatureToggles: TxHistoryFeatureToggles,
@NetworkMoshi moshi: Moshi,
): OnrampRepository {
return DefaultOnrampRepository(
@ -71,6 +75,8 @@ internal object OnrampDataModule {
countriesStore = countriesStore,
walletManagersFacade = walletManagersFacade,
dataSignatureVerifier = dataSignatureVerifier,
expressHistoryDao = expressHistoryDao,
txHistoryFeatureToggles = txHistoryFeatureToggles,
moshi = moshi,
)
}

View file

@ -29,7 +29,7 @@ dependencies {
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)

View file

@ -65,7 +65,26 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
isEnabled: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = applyCategory(current, category, isEnabled)
val updated = current.withCategory(category, isEnabled)
putAndCommit(userWalletId, updated)
}
override suspend fun setAllPreferences(
userWalletId: UserWalletId,
transactionAlerts: Boolean,
offersUpdates: Boolean,
priceAlerts: Boolean,
): Either<Throwable, Unit> = Either.catch {
val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId)
val updated = current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts),
offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates),
priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts),
)
putAndCommit(userWalletId, updated)
}
private suspend fun putAndCommit(userWalletId: UserWalletId, updated: WalletPushNotificationPreferences) {
withContext(dispatchers.io) {
// TODO: uncomment when api is ready
// tangemTechApi.updatePushNotificationPreferences(
@ -80,22 +99,6 @@ internal class DefaultWalletPushNotificationPreferencesRepository(
cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) }
}
private fun applyCategory(
current: WalletPushNotificationPreferences,
category: PushNotificationCategory,
isEnabled: Boolean,
): WalletPushNotificationPreferences = when (category) {
PushNotificationCategory.TransactionAlerts -> current.copy(
transactionAlerts = current.transactionAlerts.copy(isEnabled = isEnabled),
)
PushNotificationCategory.OffersUpdates -> current.copy(
offersUpdates = current.offersUpdates.copy(isEnabled = isEnabled),
)
PushNotificationCategory.PriceAlerts -> current.copy(
priceAlerts = current.priceAlerts.copy(isEnabled = isEnabled),
)
}
// TODO remove when api is ready, use api methods to load real settings
private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences {
val areTransactionAlertsEnabled = appPreferencesStore

View file

@ -19,7 +19,7 @@ import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
class DefaultWalletPushNotificationPreferencesRepositoryTest {

View file

@ -29,7 +29,7 @@ dependencies {
kapt(deps.hilt.kapt)
/** Tests */
testImplementation(deps.test.junit)
testImplementation(deps.test.junit5)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)

View file

@ -9,7 +9,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class Bip321PaymentUriParserTest {

View file

@ -8,7 +8,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.QrResult
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class DefaultQrScanningEventsRepositoryTest {

View file

@ -9,7 +9,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class Eip681PaymentUriParserTest {

View file

@ -8,7 +8,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class QrContentClassifierTest {

View file

@ -9,7 +9,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class SolanaPaymentUriParserTest {

View file

@ -9,7 +9,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class TronPaymentUriParserTest {

View file

@ -8,11 +8,6 @@ plugins {
android {
namespace = "com.tangem.data.quotes"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
@ -47,7 +42,6 @@ dependencies {
// endregion
// region Tests
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// endregion

View file

@ -97,7 +97,7 @@ internal class QuoteStatusConverterTest {
priceChange24h = BigDecimal.ONE,
priceChange1w = null,
priceChange30d = null,
priceUsd = BigDecimal.ONE
priceUsd = BigDecimal.ONE,
),
),
expected = QuoteStatus(

View file

@ -240,6 +240,7 @@ internal class DefaultMultiQuoteStatusFetcherTest {
),
)
val fields = setOf(QuotesFetcher.Field.PRICE, QuotesFetcher.Field.PRICE_CHANGE_24H, QuotesFetcher.Field.PRICE_USD)
val fields =
setOf(QuotesFetcher.Field.PRICE, QuotesFetcher.Field.PRICE_CHANGE_24H, QuotesFetcher.Field.PRICE_USD)
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.data.quotes.multi
import arrow.core.right
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
import com.tangem.datasource.appcurrency.AppCurrencyResponseStore
@ -11,7 +11,7 @@ import com.tangem.test.core.getEmittedValues
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -1,25 +1,32 @@
package com.tangem.data.quotes.single
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleQuoteStatusProducerTest {
private val params = SingleQuoteStatusProducer.Params(
@ -27,14 +34,22 @@ internal class DefaultSingleQuoteStatusProducerTest {
)
private val quotesStore = mockk<QuotesStatusesStore>()
private val flowProducerTools: FlowProducerTools = mockk()
private val producer = DefaultSingleQuoteStatusProducer(
params = params,
quotesStatusesStore = quotesStore,
flowProducerTools = flowProducerTools,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private fun TestScope.createProducer(): DefaultSingleQuoteStatusProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultSingleQuoteStatusProducer(
params = params,
quotesStatusesStore = quotesStore,
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
}
@Test
fun `test that flow is mapped for network from params`() = runTest {
@ -48,7 +63,7 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeQuote
val actual = producer.produce()
val actual = createProducer().produce()
verify { quotesStore.get() }
@ -64,35 +79,29 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
actual.test {
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
val updatedStatus = QuoteStatus(
rawCurrencyId = params.rawCurrencyId,
value = QuoteStatus.Data(
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
)
storeQuote.emit(value = setOf(updatedStatus))
Truth.assertThat(awaitItem()).isEqualTo(updatedStatus)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
val updatedStatus = QuoteStatus(
rawCurrencyId = params.rawCurrencyId,
value = QuoteStatus.Data(
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
)
storeQuote.emit(value = setOf(updatedStatus))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -101,26 +110,21 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
actual.test {
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
// same status again -> filtered out by distinctUntilChanged
storeQuote.emit(value = setOf(status))
expectNoEvents()
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
storeQuote.emit(value = setOf(status))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -148,21 +152,22 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
val values1 = getEmittedValues(flow = actual)
actual.test {
val fallbackStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
Truth.assertThat(awaitItem()).isEqualTo(fallbackStatus)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
innerFlow.emit(value = true)
Truth.assertThat(awaitItem()).isEqualTo(status)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -175,12 +180,14 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeFlow
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(0)
actual.test {
// params currency (BTC) is not in the store -> nothing is emitted
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -1,10 +1,10 @@
package com.tangem.data.quotes.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency

View file

@ -2,10 +2,10 @@ package com.tangem.data.quotes.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.data.quote.MockQuoteResponseFactory
import com.tangem.common.test.data.quote.toDomain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency

View file

@ -12,11 +12,6 @@ plugins {
android {
namespace = "com.tangem.data.settings"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(projects.core.datasource)
@ -34,7 +29,6 @@ dependencies {
// region Test
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
// region Others dependencies

View file

@ -12,11 +12,6 @@ plugins {
android {
namespace = "com.tangem.data.staking"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core modules */
implementation(projects.core.datasource)
@ -69,7 +64,6 @@ dependencies {
// endregion
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
testImplementation(projects.test.core)

View file

@ -12,18 +12,26 @@ import com.tangem.domain.models.staking.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import app.cash.turbine.test
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultMultiStakingBalanceProducerTest {
private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011"))
@ -41,6 +49,25 @@ internal class DefaultMultiStakingBalanceProducerTest {
dispatchers = dispatchers,
)
// Producer wired with a real test FlowProducerTools (shareIn + retry + distinctUntilChanged)
// for produceWithFallback() cases.
private fun TestScope.createProducer(): DefaultMultiStakingBalanceProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultMultiStakingBalanceProducer(
params = params,
stakeKitBalancesStore = stakeKitBalancesStore,
p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
}
@Test
fun `test that flow is mapped for user wallet id from params`() = runTest {
val balances = setOf(
@ -113,32 +140,26 @@ internal class DefaultMultiStakingBalanceProducerTest {
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
val actual = createProducer().produceWithFallback()
// check after producer.produce()
verify { stakeKitBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit
val wrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
actual.test {
val wrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(wrappers)
networksStatusesFlow.emit(wrappers)
Truth.assertThat(awaitItem()).isEqualTo(wrappers)
val values1 = getEmittedValues(flow = actual)
// same balances again -> filtered out by distinctUntilChanged
networksStatusesFlow.emit(wrappers)
expectNoEvents()
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(wrappers)
// second emit
networksStatusesFlow.emit(wrappers)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2.first()).isEqualTo(wrappers)
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -162,23 +183,24 @@ internal class DefaultMultiStakingBalanceProducerTest {
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
// check after producer.produce()
verify { stakeKitBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values1 = getEmittedValues(flow = actual)
actual.test {
// first collection throws -> retryWhen emits the empty fallback, then waits 2s
Truth.assertThat(awaitItem()).isEqualTo(emptySet<StakingBalance>())
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(emptySet<StakingBalance>()))
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
innerFlow.emit(value = true)
Truth.assertThat(awaitItem()).isEqualTo(balances)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balances))
cancelAndIgnoreRemainingEvents()
}
}
@Test

View file

@ -11,22 +11,29 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import app.cash.turbine.test
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultSingleStakingBalanceProducerTest {
@ -48,6 +55,23 @@ internal class DefaultSingleStakingBalanceProducerTest {
flowProducerTools = flowProducerTools,
)
private fun TestScope.createProducer(): DefaultSingleStakingBalanceProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultSingleStakingBalanceProducer(
params = params,
multiStakingBalanceSupplier = multiNetworkStatusSupplier,
analyticsExceptionHandler = analyticsExceptionHandler,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
)
}
@BeforeEach
fun resetMocks() {
clearMocks(multiNetworkStatusSupplier, analyticsExceptionHandler)
@ -77,7 +101,6 @@ internal class DefaultSingleStakingBalanceProducerTest {
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Disabled
@Test
fun `flow is updated if staking balance is updated`() = runTest {
// Arrange
@ -86,31 +109,23 @@ internal class DefaultSingleStakingBalanceProducerTest {
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val updatedBalance = StakingBalance.Error(stakingId = tonId)
producerFlow.test {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
multiFlow.emit(value = setOf(balance))
Truth.assertThat(awaitItem()).isEqualTo(balance)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
val actual1 = getEmittedValues(flow = producerFlow)
val updatedBalance = StakingBalance.Error(stakingId = tonId)
multiFlow.emit(value = setOf(updatedBalance))
Truth.assertThat(awaitItem()).isEqualTo(updatedBalance)
// Assert (first emit)
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(balance)
// Act (second emit)
multiFlow.emit(value = setOf(updatedBalance))
val actual2 = getEmittedValues(flow = producerFlow)
// Assert (second emit)
Truth.assertThat(actual2).hasSize(2)
Truth.assertThat(actual2).containsExactly(balance, updatedBalance)
cancelAndIgnoreRemainingEvents()
}
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Disabled
@Test
fun `flow is filtered the same status`() = runTest {
// Arrange
@ -119,30 +134,23 @@ internal class DefaultSingleStakingBalanceProducerTest {
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
producerFlow.test {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
multiFlow.emit(value = setOf(balance))
Truth.assertThat(awaitItem()).isEqualTo(balance)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
val actual1 = getEmittedValues(flow = producerFlow)
// same balance again -> filtered out by distinctUntilChanged
multiFlow.emit(value = setOf(balance))
expectNoEvents()
// Assert (first emit)
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(balance)
// Act (second emit)
multiFlow.emit(value = setOf(balance))
val actual2 = getEmittedValues(flow = producerFlow)
// Assert (second emit)
Truth.assertThat(actual2).hasSize(1)
Truth.assertThat(actual2).containsExactly(balance)
cancelAndIgnoreRemainingEvents()
}
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Disabled
@Test
fun `flow throws exception`() = runTest {
// Arrange
@ -163,23 +171,22 @@ internal class DefaultSingleStakingBalanceProducerTest {
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
// Act (first emit)
val actual1 = getEmittedValues(flow = producerFlow)
producerFlow.test {
// first collection throws -> retryWhen emits the fallback, then waits 2s
val fallbackStatus = StakingBalance.Error(stakingId = tonId.copy(address = "0x1"))
Truth.assertThat(awaitItem()).isEqualTo(fallbackStatus)
// Assert (first emit)
val fallbackStatus = StakingBalance.Error(stakingId = tonId.copy(address = "0x1"))
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(fallbackStatus)
Truth.assertThat(awaitItem()).isEqualTo(balance)
// Act (second emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
Truth.assertThat(actual2).hasSize(1)
Truth.assertThat(actual2).containsExactly(balance)
cancelAndIgnoreRemainingEvents()
}
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}

View file

@ -1,16 +1,16 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -2,9 +2,9 @@ package com.tangem.data.staking.store
import androidx.datastore.core.DataStore
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.staking.StakingBalance
@ -13,7 +13,7 @@ import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]

View file

@ -1,9 +1,9 @@
package com.tangem.data.staking.store
import com.google.common.truth.Truth
import com.tangem.common.test.TestAppCoroutineScope
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.data.staking.MockYieldBalanceWrapperDTOFactory
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.test.core.datastore.MockStateDataStore
import com.tangem.data.staking.toDomain
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.datastore.RuntimeSharedStore
@ -13,7 +13,7 @@ import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]
@ -148,8 +148,10 @@ internal class StakingBalancesStoreUpdateMethodsTest {
store.storeError(userWalletId = userWalletId, stakingIds = setOf(stakingId))
// storeError keeps the existing (CACHE) balance and downgrades its source to ONLY_CACHE.
// toDomain(ONLY_CACHE) can't express this: the converter maps any non-CACHE source to ACTUAL.
val runtimeExpected = mapOf(
userWalletId to setOf(wrapper.toDomain(source = StatusSource.ONLY_CACHE)),
userWalletId to setOf(wrapper.toDomain(source = StatusSource.CACHE).copySealed(source = StatusSource.ONLY_CACHE)),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)

View file

@ -10,11 +10,6 @@ plugins {
android {
namespace = "com.tangem.data.swap"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Core */
implementation(projects.core.datasource)
@ -46,6 +41,9 @@ dependencies {
exclude(module = "joda-time")
}
/** Core */
implementation(projects.core.configToggles)
/** Libs */
implementation(projects.libs.blockchainSdk)
implementation(projects.libs.crypto)
@ -62,7 +60,6 @@ dependencies {
kapt(deps.hilt.kapt)
/** Test */
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
testImplementation(projects.test.core)

View file

@ -6,9 +6,10 @@ import arrow.core.toOption
import com.squareup.moshi.Moshi
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.swap.converter.SwapDataConverter
import com.tangem.data.swap.converter.SwapStatusConverter
import com.tangem.data.swap.converter.TokenInfoConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
@ -57,12 +58,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
private val dataSignatureVerifier: DataSignatureVerifier,
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
private val featureTogglesManager: FeatureTogglesManager,
@NetworkMoshi moshi: Moshi,
) : SwapRepositoryV2 {
private val swapDataConverter = SwapDataConverter()
private val tokenInfoConverter = TokenInfoConverter()
private val exchangeStatusConverter = SwapStatusConverter()
private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java)
override suspend fun getPairs(
@ -401,22 +402,6 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
}
}
override suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): SwapStatusModel =
withContext(coroutineDispatcher.io) {
exchangeStatusConverter.convert(
tangemExpressApi
.getExchangeStatus(
userWalletId = userWallet.walletId.stringValue,
refCode = ExpressUtils.getRefCode(
userWallet = userWallet,
appPreferencesStore = appPreferencesStore,
),
txId = txId,
)
.getOrThrow(),
)
}
private suspend fun CoroutineScope.getPairsInternal(
userWallet: UserWallet,
initialCurrency: CryptoCurrency,
@ -544,16 +529,21 @@ internal class DefaultSwapRepositoryV2 @Inject constructor(
return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString()
}
private fun List<ExpressProvider>.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) =
filter { provider ->
// !!!WARNING!!! Filter out dex provider if yield supply is active
val yieldSupplyStatus = cryptoCurrencyStatus?.value?.yieldSupplyStatus
if (yieldSupplyStatus != null && yieldSupplyStatus.isActive) {
provider.type == ExpressProviderType.CEX
} else {
true
private fun List<ExpressProvider>.filterYieldSupplyProvider(
cryptoCurrencyStatus: CryptoCurrencyStatus?,
): List<ExpressProvider> {
return if (featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED)) {
this
} else {
filter { provider ->
if (cryptoCurrencyStatus?.value?.yieldSupplyStatus?.isActive == true) {
provider.type == ExpressProviderType.CEX
} else {
true
}
}
}
}
}
private val MEMO_RESTRICTED_NETWORKS = setOf(

View file

@ -1,24 +0,0 @@
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

@ -18,6 +18,7 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.swap.SwapErrorResolver
import com.tangem.domain.swap.SwapRepositoryV2
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.domain.swap.SwapTransactionRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -49,6 +50,7 @@ internal object SwapDataModule {
dataSignatureVerifier: DataSignatureVerifier,
singleQuoteStatusSupplier: SingleQuoteStatusSupplier,
singleQuoteStatusFetcher: SingleQuoteStatusFetcher,
featureTogglesManager: FeatureTogglesManager,
@NetworkMoshi moshi: Moshi,
): SwapRepositoryV2 {
return DefaultSwapRepositoryV2(
@ -60,6 +62,7 @@ internal object SwapDataModule {
moshi = moshi,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
featureTogglesManager = featureTogglesManager,
)
}

View file

@ -24,6 +24,8 @@ import com.tangem.domain.swap.models.SwapAmountType
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.models.SwapStatus
import com.tangem.domain.swap.models.SwapTxType
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
@ -43,6 +45,9 @@ internal class DefaultSwapRepositoryV2Test {
private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk()
private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val featureTogglesManager: FeatureTogglesManager = mockk {
every { isFeatureEnabled(any()) } returns false
}
private val repository = DefaultSwapRepositoryV2(
tangemExpressApi = tangemExpressApi,
@ -52,6 +57,7 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier = dataSignatureVerifier,
singleQuoteStatusSupplier = singleQuoteStatusSupplier,
singleQuoteStatusFetcher = singleQuoteStatusFetcher,
featureTogglesManager = featureTogglesManager,
moshi = moshi,
)
@ -64,7 +70,9 @@ internal class DefaultSwapRepositoryV2Test {
dataSignatureVerifier,
singleQuoteStatusSupplier,
singleQuoteStatusFetcher,
featureTogglesManager,
)
every { featureTogglesManager.isFeatureEnabled(any()) } returns false
}
// region getPairs(SwapCurrencyStatus, SwapCurrencyStatus)
@ -441,35 +449,6 @@ internal class DefaultSwapRepositoryV2Test {
// endregion
// region getExchangeStatus
@Test
fun `getExchangeStatus returns converted status model`() = runTest {
// Arrange
val statusResponse = ExchangeStatusResponse(
providerId = PROVIDER_ID,
status = ExchangeStatus.Finished,
externalTxId = "ext-tx-1",
externalTxUrl = "https://example.com/tx/1",
error = null,
)
coEvery {
tangemExpressApi.getExchangeStatus(any(), any(), any())
} returns ApiResponse.Success(statusResponse)
// Act
val result = repository.getExchangeStatus(userWallet = userWallet, txId = "tx-123")
// Assert
assertThat(result.providerId).isEqualTo(PROVIDER_ID)
assertThat(result.status).isEqualTo(SwapStatus.Finished)
assertThat(result.txId).isEqualTo("ext-tx-1")
assertThat(result.txExternalUrl).isEqualTo("https://example.com/tx/1")
}
// endregion
// region swapTransactionSent
@Test
@ -511,8 +490,9 @@ internal class DefaultSwapRepositoryV2Test {
// region filterYieldSupplyProvider
@Test
fun `getPairs filters out DEX providers when yield supply is active`() = runTest {
fun `getPairs filters out DEX providers when yield supply is active and flag is off`() = runTest {
// Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns false
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
@ -558,6 +538,54 @@ internal class DefaultSwapRepositoryV2Test {
assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX)
}
@Test
fun `getPairs keeps DEX providers when yield supply is active and flag is on`() = runTest {
// Arrange
every { featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED) } returns true
val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin)
val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin)
val primarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = primaryStatus,
account = mockk(),
)
val secondarySwapCurrencyStatus = SwapCurrencyStatus(
userWallet = userWallet,
status = secondaryStatus,
account = mockk(),
)
val swapPair = SwapPair(
from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID),
to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID),
providers = listOf(
SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)),
),
)
coEvery {
tangemExpressApi.getPairs(any(), any(), any())
} returns ApiResponse.Success(listOf(swapPair))
coEvery {
expressRepository.getProviders(any(), any())
} returns listOf(dexProvider, cexProvider)
// Act
val result = repository.getPairs(
primarySwapCurrencyStatus = primarySwapCurrencyStatus,
secondarySwapCurrencyStatus = secondarySwapCurrencyStatus,
filterProviderTypes = emptyList(),
swapTxType = SwapTxType.Swap,
)
// Assert — both providers should remain
assertThat(result).hasSize(2)
val providers = result.first().providers
assertThat(providers).hasSize(2)
}
// endregion
// region getSwapData

View file

@ -11,11 +11,6 @@ plugins {
android {
namespace = "com.tangem.data.tokens"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Data
@ -50,7 +45,7 @@ dependencies {
// endregion
// region Project - Features API
implementation(projects.features.sendV2.api)
implementation(projects.features.send.api)
// endregion
// region Tangem SDKs
@ -78,7 +73,6 @@ dependencies {
// endregion
// region Tests
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// endregion

View file

@ -9,11 +9,6 @@ plugins {
android {
namespace = "com.tangem.data.transaction"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Tangem SDKs */
@ -39,7 +34,7 @@ dependencies {
implementation(projects.domain.demo)
/** Api */
implementation(projects.features.sendV2.api)
implementation(projects.features.send.api)
/** DI */
implementation(deps.hilt.android)
@ -50,7 +45,6 @@ dependencies {
/** tests */
testImplementation(projects.common.test)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)

View file

@ -16,6 +16,14 @@ internal class DefaultFeeErrorResolver : FeeErrorResolver {
is BlockchainSdkError.Sui.OneSuiRequired -> {
GetFeeError.BlockchainErrors.SuiOneCoinRequired
}
is BlockchainSdkError.Ethereum.EstimateOverrideError -> {
GetFeeError.EstimateOverrideError(
blockchain = throwable.blockchain,
tokenSymbol = throwable.tokenSymbol,
rpcProvider = throwable.rpcProvider,
error = throwable.underlyingError,
)
}
else -> GetFeeError.DataError(throwable)
}
}

View file

@ -0,0 +1,71 @@
package com.tangem.data.transaction.error
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.domain.transaction.error.GetFeeError
import org.junit.jupiter.api.Test
/**
* Tests for [DefaultFeeErrorResolver] the [Throwable] -> [GetFeeError] resolver. Mirrors the
* mapping matrix of `ErrorsMapper.mapToFeeError` but driven through `resolve(throwable)`.
*
* Focuses on the [REDACTED_TASK_KEY] addition: [BlockchainSdkError.Ethereum.EstimateOverrideError] must be
* resolved to [GetFeeError.EstimateOverrideError] field-by-field; representative other chains map
* to their dedicated [GetFeeError.BlockchainErrors]; everything else falls through to
* [GetFeeError.DataError].
*/
internal class DefaultFeeErrorResolverTest {
private val resolver = DefaultFeeErrorResolver()
@Test
fun `GIVEN EstimateOverrideError THEN resolves to GetFeeError EstimateOverrideError field by field`() {
val sdkError = BlockchainSdkError.Ethereum.EstimateOverrideError(
blockchain = "ethereum",
tokenSymbol = "USDT",
rpcProvider = "infura",
underlyingError = "execution reverted",
)
val result = resolver.resolve(sdkError)
assertThat(result).isInstanceOf(GetFeeError.EstimateOverrideError::class.java)
val mapped = result as GetFeeError.EstimateOverrideError
assertThat(mapped.blockchain).isEqualTo("ethereum")
assertThat(mapped.tokenSymbol).isEqualTo("USDT")
assertThat(mapped.rpcProvider).isEqualTo("infura")
assertThat(mapped.error).isEqualTo("execution reverted")
}
@Test
fun `GIVEN TronActivationError THEN resolves to TronActivationError`() {
// AccountActivationError is a class taking an int code, not an object.
val result = resolver.resolve(BlockchainSdkError.Tron.AccountActivationError(code = 0))
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.TronActivationError)
}
@Test
fun `GIVEN KaspaZeroUtxoError THEN resolves to KaspaZeroUtxo`() {
val result = resolver.resolve(BlockchainSdkError.Kaspa.ZeroUtxoError)
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.KaspaZeroUtxo)
}
@Test
fun `GIVEN SuiOneSuiRequired THEN resolves to SuiOneCoinRequired`() {
val result = resolver.resolve(BlockchainSdkError.Sui.OneSuiRequired)
assertThat(result).isEqualTo(GetFeeError.BlockchainErrors.SuiOneCoinRequired)
}
@Test
fun `GIVEN unknown error THEN resolves to DataError preserving the throwable`() {
val sdkError = BlockchainSdkError.CustomError("boom")
val result = resolver.resolve(sdkError)
assertThat(result).isInstanceOf(GetFeeError.DataError::class.java)
assertThat((result as GetFeeError.DataError).cause).isEqualTo(sdkError)
}
}

View file

@ -9,12 +9,17 @@ android {
namespace = "com.tangem.data.txhistory"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
implementation(projects.data.common)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.core.pagination)
implementation(projects.core.analytics)
implementation(projects.domain.legacy)
implementation(projects.domain.common)
@ -23,7 +28,11 @@ dependencies {
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.express.models)
implementation(projects.domain.wallets.models)
implementation(projects.domain.wallets)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
implementation(projects.libs.blockchainSdk)
@ -34,4 +43,11 @@ dependencies {
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)
// region Test
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testImplementation(projects.test.mock)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
}

View file

@ -1,51 +1,35 @@
package com.tangem.data.txhistory.di
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.txhistory.fetcher.DefaultAppTxHistoryFetcher
import com.tangem.data.txhistory.fetcher.DefaultTxHistoryFetcherUtils
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils
import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository
import com.tangem.data.txhistory.repository.RefactoredTxHistoryRepository
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.repository.TxHistoryRepository
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Binds
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 TxHistoryDataModule {
internal interface TxHistoryDataModule {
@Provides
@Binds
@Singleton
fun provideTxHistoryRepository(
cacheRegistry: CacheRegistry,
walletManagersFacade: WalletManagersFacade,
userWalletsListRepository: UserWalletsListRepository,
txHistoryItemsStore: TxHistoryItemsStore,
dispatchers: CoroutineDispatcherProvider,
): TxHistoryRepository = DefaultTxHistoryRepository(
cacheRegistry = cacheRegistry,
walletManagersFacade = walletManagersFacade,
userWalletsListRepository = userWalletsListRepository,
txHistoryItemsStore = txHistoryItemsStore,
dispatchers = dispatchers,
)
fun provideTxHistoryRepository(default: DefaultTxHistoryRepository): TxHistoryRepository
@Provides
@Binds
@Singleton
fun provideTxHistoryRepositoryV2(
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
txHistoryItemsStore: TxHistoryItemsStore,
cacheRegistry: CacheRegistry,
): TxHistoryRepositoryV2 = RefactoredTxHistoryRepository(
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
txHistoryItemsStore = txHistoryItemsStore,
cacheRegistry = cacheRegistry,
)
fun provideTxHistoryRepositoryV2(default: RefactoredTxHistoryRepository): TxHistoryRepositoryV2
@Binds
@Singleton
fun provideAppTxHistoryFetcher(default: DefaultAppTxHistoryFetcher): AppTxHistoryFetcher
@Binds
fun provideTxHistoryFetcherUtils(default: DefaultTxHistoryFetcherUtils): TxHistoryFetcherUtils
}

View file

@ -0,0 +1,148 @@
package com.tangem.data.txhistory.fetcher
import androidx.annotation.VisibleForTesting
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelScope
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTrigger
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.txhistory.fetcher.AccountTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.ExpressTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.walletmanager.WalletManagersFacade
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.*
import java.util.concurrent.ConcurrentHashMap
internal class DefaultAccountTxHistoryFetcher @AssistedInject constructor(
@Assisted override val accountId: AccountId,
private val utils: TxHistoryFetcherUtils,
private val singleAccountSupplier: SingleAccountSupplier,
private val paymentAccountCurrency: GetPaymentAccountCryptoCurrencyStatusUseCase,
private val expressFetcherFactory: DefaultExpressTxHistoryFetcher.Factory,
private val walletManagersFacade: WalletManagersFacade,
) : AccountTxHistoryFetcher, TxHistoryFetcherUtils by utils {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val expressFetchers = ConcurrentHashMap<String, ExpressTxHistoryFetcher>()
init {
defaultLaunchIn(buildFlow())
}
override suspend fun invoke(params: TxHistoryFetchTrigger) {
sendTrigger(params)
}
override fun close() {
cancelScope()
expressFetchers.forEach { (_, fetcher) -> fetcher.close() }
expressFetchers.clear()
}
private fun buildFlow(): Flow<Unit> = channelFlow {
val accountFlow = singleAccountSupplier(accountId).stateIn(this)
when (val account = accountFlow.value) {
is Account.CryptoPortfolio -> {
account.getExpressKeys().createExpressFetcher()
accountFlow
.filterIsInstance<Account.CryptoPortfolio>()
.controlFetchersForCryptoAccount()
.launchIn(this)
}
is Account.Payment -> {
paymentAccountCurrency.invokeSync(walletId)
.getOrNull()
?.controlFetchersForPaymentAccount()
controlFetchersForPaymentAccount()
.launchIn(this)
}
// Virtual account tx-history isn't wired yet (separate task) — no express fetchers for now.
is Account.Virtual -> Unit
}
receiveTrigger().onEach { trigger ->
when (trigger) {
is TxHistoryFetchTrigger.TokenDetailsOpen -> {
val addressKey = getAddress(trigger.walletId, trigger.currency) ?: return@onEach
expressFetchers[addressKey]?.invoke(trigger)
}
is TxHistoryFetchTrigger.TokenDetailsPTR -> {
val addressKey = getAddress(trigger.walletId, trigger.currency) ?: return@onEach
expressFetchers[addressKey]?.invoke(trigger)
}
}
}.collect {}
}
private fun controlFetchersForPaymentAccount(): Flow<Unit> {
return paymentAccountCurrency(walletId)
.map { pair -> pair.controlFetchersForPaymentAccount() }
}
private suspend fun Pair<AccountStatus.Payment, CryptoCurrencyStatus>?.controlFetchersForPaymentAccount() {
val (_, paymentCurrency) = this ?: return
val paymentNetwork = paymentCurrency.currency.network
val address = getAddress(walletId, paymentCurrency.currency)
if (paymentNetwork.isSupportExpressTxHistory() && !address.isNullOrBlank()) {
getOrPutExpressFetcher(address)
} else {
// single currency for payment account, so we can close all(one)
expressFetchers.forEach { (_, fetcher) -> fetcher.close() }
expressFetchers.clear()
}
}
private fun Flow<Account.CryptoPortfolio>.controlFetchersForCryptoAccount(): Flow<Unit> {
return map { account ->
val newExpressKeys = account.getExpressKeys()
val previousExpressKeys = expressFetchers.keys
val removed = previousExpressKeys - newExpressKeys
newExpressKeys.createExpressFetcher()
removed.forEach { address -> expressFetchers.remove(address)?.close() }
}
}
private fun Set<String>.createExpressFetcher() = this.forEach { address -> getOrPutExpressFetcher(address) }
private suspend fun Account.CryptoPortfolio.getExpressKeys(): Set<String> {
val currencies = this.cryptoCurrencies
val onlyCoins = currencies.filterIsInstance<CryptoCurrency.Coin>()
val networks = onlyCoins.map { coin -> coin.network }
val newExpressKeys = networks
.filter { net -> net.isSupportExpressTxHistory() }
.mapNotNull { net -> getAddress(walletId, net) }
.toSet()
return newExpressKeys
}
@Suppress("FunctionOnlyReturningConstant") // todo txhistory check
private fun Network.isSupportExpressTxHistory(): Boolean {
return true
}
private suspend fun getAddress(userWalletId: UserWalletId, currencies: CryptoCurrency): String? =
getAddress(userWalletId, currencies.network)
private suspend fun getAddress(userWalletId: UserWalletId, network: Network): String? =
walletManagersFacade.getDefaultAddress(userWalletId, network)
private fun getOrPutExpressFetcher(address: String): ExpressTxHistoryFetcher {
return expressFetchers.computeIfAbsent(address) { expressFetcherFactory.create(address, accountId) }
}
@AssistedFactory
internal interface Factory {
fun create(accountId: AccountId): DefaultAccountTxHistoryFetcher
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.data.txhistory.fetcher
import androidx.annotation.VisibleForTesting
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelScope
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTrigger
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.retryThreeTimes
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.flow.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
internal class DefaultAppTxHistoryFetcher @Inject constructor(
private val utils: TxHistoryFetcherUtils,
private val expressRepository: ExpressRepository,
private val getWalletsUseCase: GetWalletsUseCase,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
private val walletTxHistoryFetcherFactory: DefaultWalletTxHistoryFetcher.Factory,
) : AppTxHistoryFetcher, TxHistoryFetcherUtils by utils {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val fetchers = ConcurrentHashMap<UserWalletId, WalletTxHistoryFetcher>()
/** Wallets whose express providers were already loaded — to load them at most once per wallet. */
private val providersLoadedWallets = mutableSetOf<UserWalletId>()
init {
defaultLaunchIn(buildFlow())
}
override suspend fun invoke(params: TxHistoryFetchTrigger) {
sendTrigger(params)
}
override fun close() {
cancelScope()
fetchers.forEach { (_, fetcher) -> fetcher.close() }
fetchers.clear()
}
private fun buildFlow(): Flow<Unit> = channelFlow {
val walletsFlow: StateFlow<Map<UserWalletId, UserWallet>> = getWalletsUseCase
.invokeAsMap(isOnlyMultiCurrency = true, filterLocked = true)
.stateIn(this)
walletsFlow.value.keys.createForNewWallets()
selectedWalletUseCase.selectedFlow()
.filter { wallet -> wallet.isMultiCurrency }
// todo txhistory some init trigger?
.onEach { wallet -> loadExpressProviders(wallet) }
.launchIn(this)
walletsFlow
.map { map -> map.keys }
.distinctUntilChanged()
// todo txhistory create for all or lazy?
.createForNewWallets()
.closeForRemovedWallets()
.launchIn(this)
receiveTrigger()
.onEach { trigger ->
when (trigger) {
is TxHistoryFetchTrigger.TokenDetailsOpen -> fetchers[trigger.walletId]?.invoke(trigger)
is TxHistoryFetchTrigger.TokenDetailsPTR -> fetchers[trigger.walletId]?.invoke(trigger)
}
}
.collect {}
}
private fun ProducerScope<*>.loadExpressProviders(wallet: UserWallet) {
// Load once per wallet: `add` returns false if this walletId was already loaded.
if (!providersLoadedWallets.add(wallet.walletId)) return
flow { emit(expressRepository.getProviders(userWallet = wallet, filterProviderTypes = emptyList())) }
.retryThreeTimes()
.launchIn(this)
}
private fun Flow<Set<UserWalletId>>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() }
private fun Set<UserWalletId>.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) }
private fun Flow<Set<UserWalletId>>.closeForRemovedWallets() = runningReduce { previousIds, newIds ->
val removedWallets = previousIds.subtract(newIds)
removedWallets.forEach { walletId -> fetchers.remove(walletId)?.close() }
newIds
}
private fun getOrPutFetcher(id: UserWalletId): WalletTxHistoryFetcher {
return fetchers.computeIfAbsent(id) { createFetcher(id) }
}
private fun createFetcher(id: UserWalletId): WalletTxHistoryFetcher {
return walletTxHistoryFetcherFactory.create(id)
}
}

View file

@ -0,0 +1,164 @@
package com.tangem.data.txhistory.fetcher
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelScope
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTriggerInstance
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.retryThreeTimes
import com.tangem.data.txhistory.repository.ExpressHistoryRepository
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.fetcher.ExpressTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryExpressTrigger
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
internal class DefaultExpressTxHistoryFetcher @AssistedInject constructor(
@Assisted override val address: String,
@Assisted private val accountId: AccountId,
private val utils: TxHistoryFetcherUtils,
private val expressSyncStateDao: ExpressSyncStateDao,
private val expressHistoryRepository: ExpressHistoryRepository,
) : ExpressTxHistoryFetcher, TxHistoryFetcherUtils by utils {
private val userWalletId: UserWalletId get() = accountId.userWalletId
private var exchangeInitialPaginationJob: Job? = null
private var exchangeDeltaPaginationJob: Job? = null
private var onrampInitialPaginationJob: Job? = null
private var onrampDeltaPaginationJob: Job? = null
init {
val receiveFlow = receiveTriggerInstance<TxHistoryExpressTrigger>()
.onEach { trigger ->
when (trigger) {
is TxHistoryFetchTrigger.TokenDetailsOpen,
is TxHistoryFetchTrigger.TokenDetailsPTR,
-> {
fetchExchange()
fetchOnramp()
}
}
}
defaultLaunchIn(receiveFlow)
}
override suspend fun invoke(params: TxHistoryExpressTrigger) {
utils.sendTrigger(params)
}
override fun close() {
cancelScope()
}
private fun fetchExchange() {
if (exchangeDeltaPaginationJob?.isActive == true) return
exchangeDeltaPaginationJob = fetcherScope.launch {
val isFirstFetch = expressSyncState() == null
if (isFirstFetch) {
flow { emit(expressHistoryRepository.fetchExchangeHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return@launch
}
if (exchangeInitialPaginationJob?.isActive != true) {
exchangeInitialPaginationJob = launch { expressInitialPagination() }
}
expressDeltaPagination()
}
}
private suspend fun expressInitialPagination() {
if (expressSyncState()?.isInitialCompleted == true) return
var hasMore = true
while (hasMore) {
val pageResult: ExchangeHistoryResponse =
flow { emit(expressHistoryRepository.fetchExchangeHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private suspend fun expressDeltaPagination() {
var hasMore = true
while (hasMore) {
val pageResult: ExchangeHistoryDeltaResponse =
flow { emit(expressHistoryRepository.fetchExchangeHistoryDelta(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private fun fetchOnramp() {
if (onrampDeltaPaginationJob?.isActive == true) return
onrampDeltaPaginationJob = fetcherScope.launch {
val isFirstFetch = onrampSyncState() == null
if (isFirstFetch) {
flow { emit(expressHistoryRepository.fetchOnrampHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return@launch
}
if (onrampInitialPaginationJob?.isActive != true) {
onrampInitialPaginationJob = launch { onrampInitialPagination() }
}
onrampDeltaPagination()
}
}
private suspend fun onrampInitialPagination() {
if (onrampSyncState()?.isInitialCompleted == true) return
var hasMore = true
while (hasMore) {
val pageResult: OnrampHistoryResponse =
flow { emit(expressHistoryRepository.fetchOnrampHistory(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private suspend fun onrampDeltaPagination() {
var hasMore = true
while (hasMore) {
val pageResult: OnrampHistoryDeltaResponse =
flow { emit(expressHistoryRepository.fetchOnrampHistoryDelta(address, userWalletId)) }
.retryThreeTimes()
.firstOrNull() ?: return
hasMore = pageResult.pagination.hasMore
}
}
private suspend fun expressSyncState(): ExpressSyncStateEntity? = expressSyncStateDao
.observe(ExpressSyncStateEntity.Type.EXCHANGE.name, address)
.first()
private suspend fun onrampSyncState(): ExpressSyncStateEntity? = expressSyncStateDao
.observe(ExpressSyncStateEntity.Type.ONRAMP.name, address)
.first()
@AssistedFactory
internal interface Factory {
fun create(address: String, accountId: AccountId): DefaultExpressTxHistoryFetcher
}
}

View file

@ -0,0 +1,102 @@
package com.tangem.data.txhistory.fetcher
import androidx.annotation.VisibleForTesting
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.cancelScope
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.defaultLaunchIn
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils.Companion.receiveTrigger
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.status.utils.AccountCryptoCurrencyOperations.getAccountCryptoCurrency
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.fetcher.AccountTxHistoryFetcher
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.*
import java.util.concurrent.ConcurrentHashMap
internal class DefaultWalletTxHistoryFetcher @AssistedInject constructor(
@Assisted override val walletId: UserWalletId,
private val utils: TxHistoryFetcherUtils,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val accountTxHistoryFetcher: DefaultAccountTxHistoryFetcher.Factory,
) : WalletTxHistoryFetcher, TxHistoryFetcherUtils by utils {
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val fetchers = ConcurrentHashMap<AccountId, AccountTxHistoryFetcher>()
init {
defaultLaunchIn(buildFlow())
}
override suspend fun invoke(params: TxHistoryFetchTrigger) {
sendTrigger(params)
}
override fun close() {
cancelScope()
fetchers.forEach { (_, fetcher) -> fetcher.close() }
fetchers.clear()
}
private fun buildFlow(): Flow<Unit> = channelFlow {
val accountListFlow = singleAccountListSupplier(walletId)
.stateIn(this)
fun accountList(): AccountList = accountListFlow.value
accountList().accounts
.mapTo(mutableSetOf()) { it.accountId }
.createForNewAccounts()
accountListFlow
.map { accountList -> accountList.accounts.mapTo(mutableSetOf()) { account -> account.accountId } }
.distinctUntilChanged()
.createForNewAccounts()
.closeForRemovedAccounts()
.launchIn(this)
receiveTrigger()
.onEach { trigger ->
when (trigger) {
is TxHistoryFetchTrigger.TokenDetailsOpen -> accountList()
.findFetcher(trigger.currency)?.invoke(trigger)
is TxHistoryFetchTrigger.TokenDetailsPTR -> accountList()
.findFetcher(trigger.currency)?.invoke(trigger)
}
}
.collect {}
}
private fun Flow<Set<AccountId>>.createForNewAccounts() = onEach { ids -> ids.createForNewAccounts() }
private fun Set<AccountId>.createForNewAccounts() = this.forEach { id -> getOrPutFetcher(id) }
private fun Flow<Set<AccountId>>.closeForRemovedAccounts() = runningReduce { previousIds, newIds ->
val removedWallets = previousIds.subtract(newIds)
removedWallets.forEach { walletId -> fetchers.remove(walletId)?.close() }
newIds
}
private fun AccountList.findFetcher(currency: CryptoCurrency): AccountTxHistoryFetcher? = this
.getAccountCryptoCurrency(currency)
.getOrNull()
?.account
?.let { account -> fetchers[account.accountId] }
private fun getOrPutFetcher(id: AccountId): AccountTxHistoryFetcher {
return fetchers.computeIfAbsent(id) { createFetcher(id) }
}
private fun createFetcher(id: AccountId): AccountTxHistoryFetcher {
return accountTxHistoryFetcher.create(id)
}
@AssistedFactory
internal interface Factory {
fun create(walletId: UserWalletId): DefaultWalletTxHistoryFetcher
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.data.txhistory.fetcher
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import javax.inject.Inject
const val TX_HISTORY_TAG = "TxHistory"
internal interface TxHistoryFetcherUtils {
val triggersBuffer: Channel<TxHistoryFetchTrigger>
val fetcherScope: CoroutineScope
val analyticsEventHandler: AnalyticsEventHandler
val analyticsExceptionHandler: AnalyticsExceptionHandler
suspend fun sendTrigger(trigger: TxHistoryFetchTrigger)
companion object {
fun TxHistoryFetcherUtils.cancelScope() = fetcherScope.cancel()
fun <T> TxHistoryFetcherUtils.defaultLaunchIn(flow: Flow<T>) = flow
.retry { error ->
logError(error)
true
}
.launchIn(fetcherScope)
@Suppress("MagicNumber")
fun <T> Flow<T>.retryThreeTimes() = retry(3) { error ->
logError(error)
delay(1000)
true
}.catch { e -> logError(e) }
fun TxHistoryFetcherUtils.receiveTrigger(): Flow<TxHistoryFetchTrigger> {
return triggersBuffer.receiveAsFlow()
}
inline fun <reified R> TxHistoryFetcherUtils.receiveTriggerInstance(): Flow<R> {
return receiveTrigger().filterIsInstance<R>()
}
fun logError(error: Throwable, message: String = error.message.orEmpty()) {
TangemLogger.withTag(TX_HISTORY_TAG).e(message, error)
}
}
}
internal class DefaultTxHistoryFetcherUtils @Inject constructor(
appScope: AppCoroutineScope,
override val analyticsEventHandler: AnalyticsEventHandler,
override val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : TxHistoryFetcherUtils {
override val triggersBuffer: Channel<TxHistoryFetchTrigger> = Channel(Channel.BUFFERED)
// todo txhistory use lifecycle scope?
override val fetcherScope: CoroutineScope = appScope + SupervisorJob()
override suspend fun sendTrigger(trigger: TxHistoryFetchTrigger) {
triggersBuffer.trySend(trigger)
}
}

View file

@ -23,8 +23,9 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
class DefaultTxHistoryRepository(
class DefaultTxHistoryRepository @Inject constructor(
private val cacheRegistry: CacheRegistry,
private val walletManagersFacade: WalletManagersFacade,
private val userWalletsListRepository: UserWalletsListRepository,

View file

@ -0,0 +1,177 @@
package com.tangem.data.txhistory.repository
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
import com.tangem.datasource.api.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
import com.tangem.datasource.api.onramp.OnrampApi
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
import com.tangem.datasource.local.converter.toEntity
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.first
import javax.inject.Inject
/**
* Fetches express (exchange & onramp) transaction history from the API and persists it into the local database.
*
*/
internal class ExpressHistoryRepository @Inject constructor(
private val exchangeApi: TangemExpressApi,
private val onrampApi: OnrampApi,
private val expressHistoryDao: ExpressHistoryDao,
private val expressSyncStateDao: ExpressSyncStateDao,
) {
suspend fun fetchExchangeHistory(
fromAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT,
): ExchangeHistoryResponse {
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
val response = exchangeApi.getHistory(
userWalletId = userWalletId.stringValue,
fromAddress = fromAddress,
cursor = state?.afterCursor,
limit = limit,
).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
previous = state,
pagination = response.pagination,
)
return response
}
suspend fun fetchExchangeHistoryDelta(
fromAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT,
): ExchangeHistoryDeltaResponse {
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
val response = exchangeApi.getHistoryDelta(
userWalletId = userWalletId.stringValue,
fromAddress = fromAddress,
cursor = state?.deltaCursor,
limit = limit,
).getOrThrow()
saveExchanges(ownerAddress = fromAddress, items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.EXCHANGE,
address = fromAddress,
pagination = response.pagination,
)
return response
}
suspend fun fetchOnrampHistory(
payoutAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT,
): OnrampHistoryResponse {
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
val response = onrampApi.getHistory(
userWalletId = userWalletId.stringValue,
payoutAddress = payoutAddress,
afterCursor = state?.afterCursor,
limit = limit,
).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items)
persistHistoryState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
previous = state,
pagination = response.pagination,
)
return response
}
suspend fun fetchOnrampHistoryDelta(
payoutAddress: String,
userWalletId: UserWalletId,
limit: Int = DEFAULT_LIMIT,
): OnrampHistoryDeltaResponse {
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
val response = onrampApi.getHistoryDelta(
userWalletId = userWalletId.stringValue,
payoutAddress = payoutAddress,
cursor = state?.deltaCursor,
limit = limit,
).getOrThrow()
saveOnramps(ownerAddress = payoutAddress, items = response.items)
persistDeltaState(
type = ExpressSyncStateEntity.Type.ONRAMP,
address = payoutAddress,
pagination = response.pagination,
)
return response
}
suspend fun syncState(type: ExpressSyncStateEntity.Type, address: String): ExpressSyncStateEntity? {
return expressSyncStateDao.observe(type = type.name, address = address).first()
}
private suspend fun saveExchanges(ownerAddress: String, items: List<ExchangeItemResponse>) {
expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) })
}
private suspend fun saveOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) })
}
private suspend fun persistHistoryState(
type: ExpressSyncStateEntity.Type,
address: String,
previous: ExpressSyncStateEntity?,
pagination: ExpressPagination,
) {
if (previous == null) {
expressSyncStateDao.upsert(
ExpressSyncStateEntity(
type = type.name,
address = address,
isInitialCompleted = !pagination.hasMore,
afterCursor = pagination.endCursor,
deltaCursor = pagination.startDeltaCursor,
),
)
} else {
expressSyncStateDao.updateHistoryCursor(
type = type.name,
address = address,
afterCursor = pagination.endCursor,
isInitialCompleted = !pagination.hasMore,
)
}
}
private suspend fun persistDeltaState(
type: ExpressSyncStateEntity.Type,
address: String,
pagination: ExpressPaginationDelta,
) {
val cursor = pagination.startCursor ?: return
expressSyncStateDao.updateDeltaCursor(type = type.name, address = address, deltaCursor = cursor)
}
private companion object {
const val DEFAULT_LIMIT = 100
}
}

View file

@ -1,9 +1,18 @@
package com.tangem.data.txhistory.repository
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.common.converter.ExpressProviderConverter
import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper
import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter
import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter
import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.TxInfo
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
import com.tangem.domain.txhistory.model.TxHistoryListConfig
@ -17,17 +26,96 @@ import com.tangem.pagination.BatchListSource
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.*
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import org.joda.time.format.ISODateTimeFormat
import javax.inject.Inject
internal class RefactoredTxHistoryRepository(
internal class RefactoredTxHistoryRepository @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
private val txHistoryItemsStore: TxHistoryItemsStore,
private val expressHistoryDao: ExpressHistoryDao,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
) : TxHistoryRepositoryV2 {
private val sdkPageConverter = SdkPageConverter()
private val expressProviderConverter = ExpressProviderConverter()
private val swapConverter = ExpressSwapConverter()
private val onrampConverter = ExpressOnrampConverter()
private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency)
override fun getExpressHistory(
userWalletId: UserWalletId,
currency: CryptoCurrency,
fromCreatedAtMillis: Long,
): Flow<List<ExpressTx>> = flow {
val network = currency.network
val rawNetwork = network.rawId
val contract = (currency as? CryptoCurrency.Token)?.contractAddress
?: ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
// bound filters the window directly in SQL. Generated in the same UTC/'Z' shape as stored values.
val fromCreatedAtIso = DateTime(fromCreatedAtMillis, DateTimeZone.UTC)
.toString(ISODateTimeFormat.dateTimeNoMillis())
val address = walletManagersFacade.getDefaultAddress(userWalletId, network).orEmpty()
val flow = combine(
flow = expressHistoryDao.observeOutgoingSwaps(
ownerAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
).distinctUntilChanged(),
flow2 = expressHistoryDao.observeIncomingSwaps(
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
activeStatuses = ExpressStatusMapper.activeExchangeStatuses,
).distinctUntilChanged(),
flow3 = expressHistoryDao.observeIncomingOnramps(
ownerAddress = address,
network = rawNetwork,
contract = contract,
fromCreatedAtIso = fromCreatedAtIso,
activeStatuses = ExpressStatusMapper.activeOnrampStatuses,
).distinctUntilChanged(),
flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(),
transform = { outgoingSwaps, incomingSwaps, onramps, providers ->
buildList<ExpressTx> {
fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert)
outgoingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = true,
)
add(swapConverter.convert(input))
}
incomingSwaps.forEach { entity ->
val input = ExpressSwapConverter.Input(
entity = entity,
provider = entity.providerId.expressProvider(),
isOutgoing = false,
)
add(swapConverter.convert(input))
}
onramps.forEach { entity ->
val input = ExpressOnrampConverter.Input(entity, entity.providerId.expressProvider())
add(onrampConverter.convert(input))
}
}
// An exchange row may satisfy both swap queries only in degenerate cases;
// keep the outgoing interpretation (added first).
.distinctBy { it.txId }
},
)
emitAll(flow)
}.flowOn(dispatchers.io)
override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,

View file

@ -0,0 +1,108 @@
package com.tangem.data.txhistory.repository.converter
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
import com.tangem.domain.express.models.ExchangeTransaction
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
import com.tangem.domain.express.models.ExpressExchangeStatus
import com.tangem.domain.express.models.ExpressOnrampStatus
import com.tangem.domain.express.models.ExpressProvider
import com.tangem.domain.express.models.ExpressTransactionAsset
import com.tangem.domain.express.models.OnrampTransaction
import com.tangem.domain.tokens.model.Amount
import com.tangem.domain.tokens.model.AmountType
import com.tangem.domain.txhistory.model.ExpressTx
import com.tangem.utils.converter.Converter
import org.joda.time.DateTime
import java.math.BigDecimal
/**
* Maps an exchange entity into a swap [ExpressTx.Swap].
*
* Direction is driven by [Input.isOutgoing]: outgoing views the swap's `from` side (pay-in, joined to
* on-chain by [ExpressExchangeEntity.payinHash]); incoming views the `to` side (payout, joined by
* [ExpressExchangeEntity.payoutHash]).
*/
internal class ExpressSwapConverter : Converter<ExpressSwapConverter.Input, ExpressTx.Swap> {
override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap(
tx = convertExchangeTransaction(value.entity, value.provider),
isOutgoing = value.isOutgoing,
txInfo = null,
)
data class Input(
val entity: ExpressExchangeEntity,
val provider: ExpressProvider?,
val isOutgoing: Boolean,
)
}
internal class ExpressOnrampConverter : Converter<ExpressOnrampConverter.Input, ExpressTx.Onramp> {
override fun convert(value: Input): ExpressTx.Onramp {
val entity = value.entity
return ExpressTx.Onramp(
tx = OnrampTransaction(
txId = entity.txId,
status = ExpressOnrampStatus.fromRaw(entity.status),
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = value.provider,
payoutHash = entity.payoutHash,
fromFiat = Amount(
currencySymbol = entity.fromCurrencyCode,
value = entity.fromAmount.toBigDecimalOrZero(),
decimals = entity.fromPrecision,
type = AmountType.FiatType(code = entity.fromCurrencyCode),
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
decimals = entity.to.decimals,
),
),
txInfo = null,
)
}
data class Input(val entity: ExpressOnrampEntity, val provider: ExpressProvider?)
}
private fun convertExchangeTransaction(entity: ExpressExchangeEntity, provider: ExpressProvider?): ExchangeTransaction {
return ExchangeTransaction(
txId = entity.txId,
status = ExpressExchangeStatus.fromRaw(entity.status),
createdAtMillis = parseIsoMillis(entity.createdAt),
provider = provider,
payinHash = entity.payinHash,
payoutHash = entity.payoutHash,
fromAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.from.network, contractAddress = entity.from.contractAddress),
amount = entity.from.amount.toBigDecimalOrZero(),
decimals = entity.from.decimals,
),
toAsset = ExpressTransactionAsset(
id = ExpressAssetId(networkId = entity.to.network, contractAddress = entity.to.contractAddress),
amount = (entity.to.actualAmount ?: entity.to.amount).toBigDecimalOrZero(),
decimals = entity.to.decimals,
),
)
}
private fun parseIsoMillis(iso: String): Long = DateTime.parse(iso).millis
private fun String?.toBigDecimalOrZero(): BigDecimal = this?.toBigDecimalOrNull() ?: BigDecimal.ZERO
/**
* Active (non-terminal) RAW status values passed to the DAO `observe` queries so in-progress deals
* stay visible beyond the time window. Derived from [ExpressExchangeStatus.isTerminal] /
* [ExpressOnrampStatus.isTerminal] so the query set and the typed terminal classification never drift.
*/
internal object ExpressStatusMapper {
val activeExchangeStatuses: List<String> =
ExpressExchangeStatus.entries.filterNot { it.isTerminal }.map { it.raw }
val activeOnrampStatuses: List<String> =
ExpressOnrampStatus.entries.filterNot { it.isTerminal }.map { it.raw }
}

View file

@ -0,0 +1,150 @@
package com.tangem.data.txhistory.fetcher
import com.google.common.truth.Truth.assertThat
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.account.supplier.SingleAccountSupplier
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.test.mock.MockAccounts
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.job
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultAccountTxHistoryFetcherTest {
private val singleAccountSupplier: SingleAccountSupplier = mockk()
private val paymentAccountCurrency: GetPaymentAccountCryptoCurrencyStatusUseCase = mockk()
private val expressFetcherFactory: DefaultExpressTxHistoryFetcher.Factory = mockk()
private val walletManagersFacade: WalletManagersFacade = mockk()
private val coin: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
private val cryptoAccount = MockAccounts.createAccount(
derivationIndex = 1,
userWalletId = WALLET_ID,
cryptoCurrencies = listOf(coin),
)
@BeforeEach
fun setup() {
clearMocks(singleAccountSupplier, paymentAccountCurrency, expressFetcherFactory, walletManagersFacade)
}
@Test
fun `creates express fetcher per coin address of a crypto portfolio account`() = runTest {
val utils = createUtils()
val accountFlow = MutableStateFlow<Account>(cryptoAccount)
every { singleAccountSupplier.invoke(cryptoAccount.accountId) } returns accountFlow
coEvery { walletManagersFacade.getDefaultAddress(WALLET_ID, coin.network) } returns ADDRESS
val expressFetcher = relaxedExpressFetcher()
every { expressFetcherFactory.create(ADDRESS, cryptoAccount.accountId) } returns expressFetcher
// Act
val fetcher = createFetcher(cryptoAccount.accountId, utils)
advanceUntilIdle()
// Assert
assertThat(fetcher.expressFetchers.keys).containsExactly(ADDRESS)
verify(exactly = 1) { expressFetcherFactory.create(ADDRESS, cryptoAccount.accountId) }
}
@Test
fun `closes express fetcher when its coin is removed from the account`() = runTest {
val utils = createUtils()
val accountFlow = MutableStateFlow<Account>(cryptoAccount)
every { singleAccountSupplier.invoke(cryptoAccount.accountId) } returns accountFlow
coEvery { walletManagersFacade.getDefaultAddress(WALLET_ID, coin.network) } returns ADDRESS
val expressFetcher = relaxedExpressFetcher()
every { expressFetcherFactory.create(ADDRESS, cryptoAccount.accountId) } returns expressFetcher
val fetcher = createFetcher(cryptoAccount.accountId, utils)
advanceUntilIdle()
assertThat(fetcher.expressFetchers.keys).containsExactly(ADDRESS)
// Act
accountFlow.value = cryptoAccount.copy(cryptoCurrencies = emptyList())
advanceUntilIdle()
// Assert
assertThat(fetcher.expressFetchers).isEmpty()
verify(exactly = 1) { expressFetcher.close() }
}
@Test
fun `routes trigger to the express fetcher of the currency address`() = runTest {
val utils = createUtils()
val accountFlow = MutableStateFlow<Account>(cryptoAccount)
every { singleAccountSupplier.invoke(cryptoAccount.accountId) } returns accountFlow
coEvery { walletManagersFacade.getDefaultAddress(WALLET_ID, coin.network) } returns ADDRESS
val expressFetcher = relaxedExpressFetcher()
every { expressFetcherFactory.create(ADDRESS, cryptoAccount.accountId) } returns expressFetcher
val fetcher = createFetcher(cryptoAccount.accountId, utils)
advanceUntilIdle()
// Act
val trigger = TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID, currency = coin)
fetcher.invoke(trigger)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { expressFetcher.invoke(trigger) }
}
@Test
fun `close cancels scope and closes all express fetchers`() = runTest {
val utils = createUtils()
val accountFlow = MutableStateFlow<Account>(cryptoAccount)
every { singleAccountSupplier.invoke(cryptoAccount.accountId) } returns accountFlow
coEvery { walletManagersFacade.getDefaultAddress(WALLET_ID, coin.network) } returns ADDRESS
val expressFetcher = relaxedExpressFetcher()
every { expressFetcherFactory.create(ADDRESS, cryptoAccount.accountId) } returns expressFetcher
val fetcher = createFetcher(cryptoAccount.accountId, utils)
advanceUntilIdle()
assertThat(fetcher.expressFetchers.keys).containsExactly(ADDRESS)
// Act
fetcher.close()
// Assert
assertThat(fetcher.expressFetchers).isEmpty()
verify(exactly = 1) { expressFetcher.close() }
assertThat(utils.fetcherScope.coroutineContext.job.isActive).isFalse()
}
private fun TestScope.createUtils(): DefaultTxHistoryFetcherUtils = DefaultTxHistoryFetcherUtils(
appScope = TestAppCoroutineScope(testScope = this),
analyticsEventHandler = mockk(relaxed = true),
analyticsExceptionHandler = mockk(relaxed = true),
)
private fun createFetcher(accountId: AccountId, utils: DefaultTxHistoryFetcherUtils) =
DefaultAccountTxHistoryFetcher(
accountId = accountId,
utils = utils,
singleAccountSupplier = singleAccountSupplier,
paymentAccountCurrency = paymentAccountCurrency,
expressFetcherFactory = expressFetcherFactory,
walletManagersFacade = walletManagersFacade,
)
private fun relaxedExpressFetcher() = mockk<DefaultExpressTxHistoryFetcher>(relaxed = true)
private companion object {
val WALLET_ID = MockAccounts.userWalletId
const val ADDRESS = "0xEthAddress"
}
}

Some files were not shown because too many files have changed in this diff Show more