Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-15 22:43:52 +03:00
commit d6f9f59866
1729 changed files with 67614 additions and 9361 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

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

@ -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)
@ -49,6 +44,5 @@ dependencies {
/* Test */
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.moshi)
}

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

@ -5,7 +5,9 @@ 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
@ -16,6 +18,7 @@ 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,
) : ExpressRepository {
@ -26,13 +29,17 @@ 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()
expressHistoryDao.upsertProviders(providers.map { it.toEntity() })
providers.map(ExpressProviderConverter()::convert)
.filterIf(filterProviderTypes.isNotEmpty()) { it.type in filterProviderTypes }
},
onError = { error ->

View file

@ -9,6 +9,7 @@ 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
@ -36,11 +37,13 @@ internal object ExpressDataModule {
@Singleton
fun provideExpressRepository(
tangemExpressApi: TangemExpressApi,
expressHistoryDao: ExpressHistoryDao,
appPreferencesStore: AppPreferencesStore,
dispatchers: CoroutineDispatcherProvider,
): ExpressRepository {
return DefaultExpressRepository(
tangemExpressApi = tangemExpressApi,
expressHistoryDao = expressHistoryDao,
appPreferencesStore = appPreferencesStore,
dispatchers = dispatchers,
)

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

@ -24,6 +24,7 @@ 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.currencies.OnrampCurrenciesStore
import com.tangem.datasource.local.onramp.pairs.OnrampPairsStore
@ -35,6 +36,7 @@ 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
@ -72,12 +74,13 @@ internal class DefaultOnrampRepository(
private val currenciesStore: OnrampCurrenciesStore,
private val walletManagersFacade: WalletManagersFacade,
private val dataSignatureVerifier: DataSignatureVerifier,
private val expressHistoryDao: ExpressHistoryDao,
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 +165,8 @@ internal class DefaultOnrampRepository(
)
.getOrThrow()
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,6 +26,7 @@ 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.*
@ -56,6 +57,7 @@ internal object OnrampDataModule {
walletManagersFacade: WalletManagersFacade,
dataSignatureVerifier: DataSignatureVerifier,
onrampCurrentCountryByIPStore: OnrampCurrentCountryByIPStore,
expressHistoryDao: ExpressHistoryDao,
@NetworkMoshi moshi: Moshi,
): OnrampRepository {
return DefaultOnrampRepository(
@ -71,6 +73,7 @@ internal object OnrampDataModule {
countriesStore = countriesStore,
walletManagersFacade = walletManagersFacade,
dataSignatureVerifier = dataSignatureVerifier,
expressHistoryDao = expressHistoryDao,
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)
@ -24,6 +29,9 @@ dependencies {
implementation(projects.domain.txhistory)
implementation(projects.domain.txhistory.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 +42,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,91 @@
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.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.flow.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
internal class DefaultAppTxHistoryFetcher @Inject constructor(
private val utils: TxHistoryFetcherUtils,
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>()
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 { }
.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 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

@ -17,8 +17,9 @@ import com.tangem.pagination.BatchListSource
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
internal class RefactoredTxHistoryRepository(
internal class RefactoredTxHistoryRepository @Inject constructor(
private val walletManagersFacade: WalletManagersFacade,
private val txHistoryItemsStore: TxHistoryItemsStore,
private val cacheRegistry: CacheRegistry,

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

View file

@ -0,0 +1,164 @@
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.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
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 DefaultAppTxHistoryFetcherTest {
private val getWalletsUseCase: GetWalletsUseCase = mockk()
private val selectedWalletUseCase: GetSelectedWalletUseCase = mockk()
private val walletFetcherFactory: DefaultWalletTxHistoryFetcher.Factory = mockk()
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
@BeforeEach
fun setup() {
clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory)
every { selectedWalletUseCase.selectedFlow() } returns emptyFlow()
}
@Test
fun `creates wallet fetcher for each new wallet`() = runTest {
val utils = createUtils()
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>())
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val walletFetcher1 = relaxedWalletFetcher()
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
val fetcher = createFetcher(utils)
advanceUntilIdle()
assertThat(fetcher.fetchers).isEmpty()
// Act
walletsFlow.value = linkedMapOf(WALLET_ID_1 to mockk())
advanceUntilIdle()
// Assert
assertThat(fetcher.fetchers.keys).containsExactly(WALLET_ID_1)
verify(exactly = 1) { walletFetcherFactory.create(WALLET_ID_1) }
}
@Test
fun `closes and removes fetcher when wallet is removed`() = runTest {
val utils = createUtils()
val walletsFlow = MutableStateFlow(
linkedMapOf<UserWalletId, UserWallet>(WALLET_ID_1 to mockk(), WALLET_ID_2 to mockk()),
)
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val walletFetcher1 = relaxedWalletFetcher()
val walletFetcher2 = relaxedWalletFetcher()
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
every { walletFetcherFactory.create(WALLET_ID_2) } returns walletFetcher2
val fetcher = createFetcher(utils)
advanceUntilIdle()
assertThat(fetcher.fetchers.keys).containsExactly(WALLET_ID_1, WALLET_ID_2)
// Act
walletsFlow.value = linkedMapOf(WALLET_ID_1 to mockk())
advanceUntilIdle()
// Assert
assertThat(fetcher.fetchers.keys).containsExactly(WALLET_ID_1)
verify(exactly = 1) { walletFetcher2.close() }
verify(inverse = true) { walletFetcher1.close() }
}
@Test
fun `routes trigger to the fetcher of the target wallet`() = runTest {
val utils = createUtils()
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>(WALLET_ID_1 to mockk()))
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val walletFetcher1 = relaxedWalletFetcher()
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Act
val trigger = TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID_1, currency = currency)
fetcher.invoke(trigger)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { walletFetcher1.invoke(trigger) }
}
@Test
fun `does nothing when trigger targets unknown wallet`() = runTest {
val utils = createUtils()
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>())
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Act
val trigger = TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID_1, currency = currency)
val result = fetcher.invoke(trigger)
advanceUntilIdle()
// Assert
assertThat(fetcher.fetchers).isEmpty()
verify(inverse = true) { walletFetcherFactory.create(any()) }
}
@Test
fun `close cancels scope and closes all child fetchers`() = runTest {
val utils = createUtils()
val walletsFlow = MutableStateFlow(linkedMapOf<UserWalletId, UserWallet>(WALLET_ID_1 to mockk()))
every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow
val walletFetcher1 = relaxedWalletFetcher()
every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1
val fetcher = createFetcher(utils)
advanceUntilIdle()
assertThat(fetcher.fetchers.keys).containsExactly(WALLET_ID_1)
// Act
fetcher.close()
// Assert
assertThat(fetcher.fetchers).isEmpty()
verify(exactly = 1) { walletFetcher1.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(utils: DefaultTxHistoryFetcherUtils) = DefaultAppTxHistoryFetcher(
utils = utils,
getWalletsUseCase = getWalletsUseCase,
selectedWalletUseCase = selectedWalletUseCase,
walletTxHistoryFetcherFactory = walletFetcherFactory,
)
private fun relaxedWalletFetcher() = mockk<DefaultWalletTxHistoryFetcher>(relaxed = true)
private companion object {
val WALLET_ID_1 = UserWalletId("001")
val WALLET_ID_2 = UserWalletId("002")
}
}

View file

@ -0,0 +1,176 @@
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.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.express.models.response.ExpressPagination
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
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.currency.CryptoCurrency
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
import com.tangem.test.mock.MockAccounts
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
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 DefaultExpressTxHistoryFetcherTest {
private val expressSyncStateDao: ExpressSyncStateDao = mockk()
private val expressHistoryRepository: ExpressHistoryRepository = mockk()
private val coin: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
@BeforeEach
fun setup() {
clearMocks(expressSyncStateDao, expressHistoryRepository)
}
@Test
fun `exposes the address it was created with`() = runTest {
val fetcher = createFetcher(createUtils())
assertThat(fetcher.address).isEqualTo(ADDRESS)
}
@Test
fun `on first trigger fetches initial exchange and onramp history for the address`() = runTest {
stubAllSuccess(hasMore = false)
val fetcher = createFetcher(createUtils())
// Act
fetcher.invoke(TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID, currency = coin))
advanceUntilIdle()
// Assert
coVerify(atLeast = 1) { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) }
coVerify(atLeast = 1) { expressHistoryRepository.fetchOnrampHistory(ADDRESS, any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) }
}
@Test
fun `continues exchange initial pagination while hasMore is true`() = runTest {
every { expressSyncStateDao.observe(any(), ADDRESS) } returns flowOf(null)
// 1st call: initial fetch in fetchExchange (pagination ignored)
// 2nd call: pagination loop, hasMore = true -> continue
// 3rd call: pagination loop, hasMore = false -> stop
coEvery { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) } returnsMany listOf(
exchangeResponse(hasMore = true),
exchangeResponse(hasMore = true),
exchangeResponse(hasMore = false),
)
coEvery { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) } returns
exchangeDeltaResponse(hasMore = false)
coEvery { expressHistoryRepository.fetchOnrampHistory(ADDRESS, any()) } returns onrampResponse(hasMore = false)
coEvery { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) } returns
onrampDeltaResponse(hasMore = false)
val fetcher = createFetcher(createUtils())
// Act
fetcher.invoke(TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID, currency = coin))
advanceUntilIdle()
// Assert
coVerify(exactly = 3) { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) }
}
@Test
fun `skips initial pagination when it is already completed`() = runTest {
every { expressSyncStateDao.observe(any(), ADDRESS) } returns flowOf(completedSyncState())
coEvery { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) } returns
exchangeDeltaResponse(hasMore = false)
coEvery { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) } returns
onrampDeltaResponse(hasMore = false)
val fetcher = createFetcher(createUtils())
// Act
fetcher.invoke(TxHistoryFetchTrigger.TokenDetailsPTR(walletId = WALLET_ID, currency = coin))
advanceUntilIdle()
// Assert: no initial history fetch, only the delta pagination runs
coVerify(exactly = 0) { expressHistoryRepository.fetchExchangeHistory(any(), any()) }
coVerify(exactly = 0) { expressHistoryRepository.fetchOnrampHistory(any(), any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) }
coVerify(exactly = 1) { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) }
}
@Test
fun `close cancels the fetcher scope`() = runTest {
val utils = createUtils()
val fetcher = createFetcher(utils)
// Act
fetcher.close()
// Assert
assertThat(utils.fetcherScope.coroutineContext.job.isActive).isFalse()
}
private fun stubAllSuccess(hasMore: Boolean) {
every { expressSyncStateDao.observe(any(), ADDRESS) } returns flowOf(null)
coEvery { expressHistoryRepository.fetchExchangeHistory(ADDRESS, any()) } returns exchangeResponse(hasMore)
coEvery { expressHistoryRepository.fetchExchangeHistoryDelta(ADDRESS, any()) } returns
exchangeDeltaResponse(hasMore)
coEvery { expressHistoryRepository.fetchOnrampHistory(ADDRESS, any()) } returns onrampResponse(hasMore)
coEvery { expressHistoryRepository.fetchOnrampHistoryDelta(ADDRESS, any()) } returns onrampDeltaResponse(hasMore)
}
private fun TestScope.createUtils(): DefaultTxHistoryFetcherUtils = DefaultTxHistoryFetcherUtils(
appScope = TestAppCoroutineScope(testScope = this),
analyticsEventHandler = mockk(relaxed = true),
analyticsExceptionHandler = mockk(relaxed = true),
)
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultExpressTxHistoryFetcher(
address = ADDRESS,
accountId = ACCOUNT_ID,
utils = utils,
expressSyncStateDao = expressSyncStateDao,
expressHistoryRepository = expressHistoryRepository,
)
private fun exchangeResponse(hasMore: Boolean) =
ExchangeHistoryResponse(items = emptyList(), pagination = pagination(hasMore))
private fun exchangeDeltaResponse(hasMore: Boolean) =
ExchangeHistoryDeltaResponse(items = emptyList(), pagination = paginationDelta(hasMore))
private fun onrampResponse(hasMore: Boolean) =
OnrampHistoryResponse(items = emptyList(), pagination = pagination(hasMore))
private fun onrampDeltaResponse(hasMore: Boolean) =
OnrampHistoryDeltaResponse(items = emptyList(), pagination = paginationDelta(hasMore))
private fun pagination(hasMore: Boolean) =
ExpressPagination(endCursor = null, startDeltaCursor = null, hasMore = hasMore)
private fun paginationDelta(hasMore: Boolean) = ExpressPaginationDelta(startCursor = null, hasMore = hasMore)
private fun completedSyncState() = ExpressSyncStateEntity(
type = ExpressSyncStateEntity.Type.EXCHANGE.name,
address = ADDRESS,
isInitialCompleted = true,
afterCursor = null,
deltaCursor = null,
)
private companion object {
val WALLET_ID = MockAccounts.userWalletId
val ACCOUNT_ID = AccountId.forMainCryptoPortfolio(WALLET_ID)
const val ADDRESS = "0xEthAddress"
}
}

View file

@ -0,0 +1,176 @@
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.account.models.AccountList
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
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 DefaultWalletTxHistoryFetcherTest {
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val accountFetcherFactory: DefaultAccountTxHistoryFetcher.Factory = mockk()
private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum
private val mainAccount = Account.CryptoPortfolio.createMainAccount(
userWalletId = WALLET_ID,
cryptoCurrencies = listOf(currency),
)
private val secondAccount = MockAccounts.createAccount(derivationIndex = 1, userWalletId = WALLET_ID)
@BeforeEach
fun setup() {
clearMocks(singleAccountListSupplier, accountFetcherFactory)
}
@Test
fun `creates account fetcher for each account in the wallet`() = runTest {
val utils = createUtils()
val accountListFlow = MutableStateFlow(accountListOf(mainAccount, secondAccount))
every { singleAccountListSupplier.invoke(WALLET_ID) } returns accountListFlow
val mainFetcher = relaxedAccountFetcher()
val secondFetcher = relaxedAccountFetcher()
every { accountFetcherFactory.create(mainAccount.accountId) } returns mainFetcher
every { accountFetcherFactory.create(secondAccount.accountId) } returns secondFetcher
// Act
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Assert
assertThat(fetcher.fetchers.keys).containsExactly(mainAccount.accountId, secondAccount.accountId)
verify(exactly = 1) { accountFetcherFactory.create(mainAccount.accountId) }
verify(exactly = 1) { accountFetcherFactory.create(secondAccount.accountId) }
}
@Test
fun `closes and removes fetcher when account is removed`() = runTest {
val utils = createUtils()
val accountListFlow = MutableStateFlow(accountListOf(mainAccount, secondAccount))
every { singleAccountListSupplier.invoke(WALLET_ID) } returns accountListFlow
val mainFetcher = relaxedAccountFetcher()
val secondFetcher = relaxedAccountFetcher()
every { accountFetcherFactory.create(mainAccount.accountId) } returns mainFetcher
every { accountFetcherFactory.create(secondAccount.accountId) } returns secondFetcher
val fetcher = createFetcher(utils)
advanceUntilIdle()
assertThat(fetcher.fetchers.keys).containsExactly(mainAccount.accountId, secondAccount.accountId)
// Act
accountListFlow.value = accountListOf(mainAccount)
advanceUntilIdle()
// Assert
assertThat(fetcher.fetchers.keys).containsExactly(mainAccount.accountId)
verify(exactly = 1) { secondFetcher.close() }
verify(inverse = true) { mainFetcher.close() }
}
@Test
fun `routes trigger to the fetcher of the account that holds the currency`() = runTest {
val utils = createUtils()
val accountListFlow = MutableStateFlow(accountListOf(mainAccount))
every { singleAccountListSupplier.invoke(WALLET_ID) } returns accountListFlow
val mainFetcher = relaxedAccountFetcher()
every { accountFetcherFactory.create(mainAccount.accountId) } returns mainFetcher
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Act
val trigger = TxHistoryFetchTrigger.TokenDetailsPTR(walletId = WALLET_ID, currency = currency)
fetcher.invoke(trigger)
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { mainFetcher.invoke(trigger) }
}
@Test
fun `does nothing when trigger currency is not present in any account`() = runTest {
val utils = createUtils()
// main account without the triggered currency
val accountListFlow = MutableStateFlow(accountListOf(Account.CryptoPortfolio.createMainAccount(WALLET_ID)))
every { singleAccountListSupplier.invoke(WALLET_ID) } returns accountListFlow
val mainFetcher = relaxedAccountFetcher()
every { accountFetcherFactory.create(any()) } returns mainFetcher
val fetcher = createFetcher(utils)
advanceUntilIdle()
// Act
val trigger = TxHistoryFetchTrigger.TokenDetailsOpen(walletId = WALLET_ID, currency = currency)
val result = fetcher.invoke(trigger)
advanceUntilIdle()
// Assert
coVerify(inverse = true) { mainFetcher.invoke(any()) }
}
@Test
fun `close cancels scope and closes all child fetchers`() = runTest {
val utils = createUtils()
val accountListFlow = MutableStateFlow(accountListOf(mainAccount, secondAccount))
every { singleAccountListSupplier.invoke(WALLET_ID) } returns accountListFlow
val mainFetcher = relaxedAccountFetcher()
val secondFetcher = relaxedAccountFetcher()
every { accountFetcherFactory.create(mainAccount.accountId) } returns mainFetcher
every { accountFetcherFactory.create(secondAccount.accountId) } returns secondFetcher
val fetcher = createFetcher(utils)
advanceUntilIdle()
assertThat(fetcher.fetchers.keys).containsExactly(mainAccount.accountId, secondAccount.accountId)
// Act
fetcher.close()
// Assert
assertThat(fetcher.fetchers).isEmpty()
verify(exactly = 1) { mainFetcher.close() }
verify(exactly = 1) { secondFetcher.close() }
assertThat(utils.fetcherScope.coroutineContext.job.isActive).isFalse()
}
private fun accountListOf(vararg accounts: Account): AccountList = AccountList(
userWalletId = WALLET_ID,
accounts = accounts.toList(),
totalAccounts = accounts.size,
totalArchivedAccounts = 0,
).getOrNull()!!
private fun TestScope.createUtils(): DefaultTxHistoryFetcherUtils = DefaultTxHistoryFetcherUtils(
appScope = TestAppCoroutineScope(testScope = this),
analyticsEventHandler = mockk(relaxed = true),
analyticsExceptionHandler = mockk(relaxed = true),
)
private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultWalletTxHistoryFetcher(
walletId = WALLET_ID,
utils = utils,
singleAccountListSupplier = singleAccountListSupplier,
accountTxHistoryFetcher = accountFetcherFactory,
)
private fun relaxedAccountFetcher() = mockk<DefaultAccountTxHistoryFetcher>(relaxed = true)
private companion object {
val WALLET_ID: UserWalletId = MockAccounts.userWalletId
}
}

View file

@ -0,0 +1,361 @@
package com.tangem.data.txhistory.repository
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.converter.toEntity
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
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.txhistory.db.dao.ExpressHistoryDao
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ExpressHistoryRepositoryTest {
private val exchangeApi: TangemExpressApi = mockk()
private val onrampApi: OnrampApi = mockk()
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
private val expressSyncStateDao: ExpressSyncStateDao = mockk(relaxUnitFun = true)
private val repository = ExpressHistoryRepository(
exchangeApi = exchangeApi,
onrampApi = onrampApi,
expressHistoryDao = expressHistoryDao,
expressSyncStateDao = expressSyncStateDao,
)
@BeforeEach
fun setup() {
clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao)
}
// region exchange history
@Test
fun `GIVEN sync state WHEN fetchExchangeHistory THEN passes after cursor and persists items`() = runTest {
// GIVEN
val item = createExchangeItem()
val response = ExchangeHistoryResponse(items = listOf(item), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
}
@Test
fun `GIVEN no sync state WHEN fetchExchangeHistory THEN passes null cursor`() = runTest {
// GIVEN
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state = null)
coEvery {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = null, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
coVerify(exactly = 1) {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = null, limit = DEFAULT_LIMIT)
}
}
@Test
fun `GIVEN custom limit WHEN fetchExchangeHistory THEN forwards limit to api`() = runTest {
// GIVEN
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID, limit = 25)
// THEN
coVerify(exactly = 1) {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25)
}
}
@Test
fun `GIVEN api error WHEN fetchExchangeHistory THEN throws and does not persist`() = runTest {
// GIVEN
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
val error = httpError()
coEvery {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Error(error).cast()
// WHEN
val thrown = runCatching { repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID) }.exceptionOrNull()
// THEN
assertThat(thrown).isEqualTo(error)
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
}
@Test
fun `GIVEN sync state WHEN fetchExchangeHistoryDelta THEN passes delta cursor and persists items`() = runTest {
// GIVEN
val item = createExchangeItem()
val response = ExchangeHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
coEvery {
exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchExchangeHistoryDelta(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
exchangeApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
}
// endregion
// region onramp history
@Test
fun `GIVEN sync state WHEN fetchOnrampHistory THEN passes after cursor and persists items`() = runTest {
// GIVEN
val item = createOnrampItem()
val response = OnrampHistoryResponse(items = listOf(item), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
}
@Test
fun `GIVEN no sync state WHEN fetchOnrampHistory THEN passes null cursor`() = runTest {
// GIVEN
val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null)
coEvery {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = any())
} returns ApiResponse.Success(response)
// WHEN
repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
coVerify(exactly = 1) {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT)
}
}
@Test
fun `GIVEN sync state WHEN fetchOnrampHistoryDelta THEN passes delta cursor and persists items`() = runTest {
// GIVEN
val item = createOnrampItem()
val response = OnrampHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
coEvery {
onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
} returns ApiResponse.Success(response)
// WHEN
val result = repository.fetchOnrampHistoryDelta(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
assertThat(result).isEqualTo(response)
coVerify(exactly = 1) {
onrampApi.getHistoryDelta(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
}
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
}
@Test
fun `GIVEN api error WHEN fetchOnrampHistory THEN throws and does not persist`() = runTest {
// GIVEN
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
val error = httpError()
coEvery {
onrampApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Error(error).cast()
// WHEN
val thrown = runCatching { repository.fetchOnrampHistory(payoutAddress = ADDRESS, userWalletId = USER_WALLET_ID) }.exceptionOrNull()
// THEN
assertThat(thrown).isEqualTo(error)
coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) }
}
// endregion
// region syncState
@Test
fun `GIVEN stored sync state WHEN syncState THEN returns first emitted value`() = runTest {
// GIVEN
val state = syncState(afterCursor = AFTER_CURSOR, deltaCursor = DELTA_CURSOR)
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state)
// WHEN
val result = repository.syncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS)
// THEN
assertThat(result).isEqualTo(state)
}
@Test
fun `GIVEN multiple items WHEN fetchExchangeHistory THEN maps every item with owner address`() = runTest {
// GIVEN
val items = listOf(
createExchangeItem(txId = "tx-1"),
createExchangeItem(txId = "tx-2"),
)
val response = ExchangeHistoryResponse(items = items, pagination = pagination())
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
coEvery {
exchangeApi.getHistory(userWalletId = USER_WALLET_ID_VALUE, fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
} returns ApiResponse.Success(response)
val saved = slot<List<ExpressExchangeEntity>>()
coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit
// WHEN
repository.fetchExchangeHistory(fromAddress = ADDRESS, userWalletId = USER_WALLET_ID)
// THEN
assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) })
assertThat(saved.captured.map { it.ownerAddress }.toSet()).containsExactly(ADDRESS)
}
// endregion
private fun stubSyncState(type: ExpressSyncStateEntity.Type, address: String, state: ExpressSyncStateEntity?) {
coEvery { expressSyncStateDao.observe(type = type.name, address = address) } returns flowOf(state)
}
private fun syncState(afterCursor: String? = null, deltaCursor: String? = null) = ExpressSyncStateEntity(
type = ExpressSyncStateEntity.Type.EXCHANGE.name,
address = ADDRESS,
isInitialCompleted = true,
afterCursor = afterCursor,
deltaCursor = deltaCursor,
)
private fun pagination() = ExpressPagination(endCursor = "end", startDeltaCursor = "delta", hasMore = false)
private fun paginationDelta() = ExpressPaginationDelta(startCursor = "start", hasMore = false)
@Suppress("UNCHECKED_CAST")
private fun <T : Any> ApiResponse.Error.cast(): ApiResponse<T> = this as ApiResponse<T>
private fun httpError() = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
message = "boom",
errorBody = null,
)
private fun createExchangeItem(txId: String = "exchange-tx-1") = ExchangeItemResponse(
txId = txId,
providerId = "changelly",
fromAddress = "0xfrom",
payinAddress = "0xpayin",
payinExtraId = null,
payoutAddress = "0xpayout",
refundAddress = null,
refundExtraId = null,
rateType = "float",
status = "finished",
externalTxId = null,
externalTxUrl = null,
payinHash = "payin-hash",
payoutHash = "payout-hash",
refundNetwork = null,
refundContractAddress = null,
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:05:00Z",
payTill = null,
averageDuration = null,
fromContractAddress = "0xfromContract",
fromNetwork = "ethereum",
fromDecimals = 18,
fromAmount = "1.0",
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "1.0",
toActualAmount = "0.99",
)
private fun createOnrampItem(txId: String = "onramp-tx-1") = OnrampItemResponse(
txId = txId,
providerId = "mercuryo",
payoutAddress = "0xpayout",
status = "finished",
failReason = null,
externalTxId = null,
externalTxUrl = null,
payoutHash = "payout-hash",
createdAt = "2026-06-01T00:00:00Z",
updatedAt = "2026-06-01T00:05:00Z",
fromCurrencyCode = "USD",
fromAmount = "100.0",
fromPrecision = 2,
toContractAddress = "0xtoContract",
toNetwork = "bitcoin",
toDecimals = 8,
toAmount = "0.001",
toActualAmount = "0.99",
paymentMethod = "card",
countryCode = "US",
)
private companion object {
const val ADDRESS = "0xowner"
val USER_WALLET_ID = UserWalletId("0123456789abcdef")
val USER_WALLET_ID_VALUE = USER_WALLET_ID.stringValue
const val AFTER_CURSOR = "after-cursor"
const val DELTA_CURSOR = "delta-cursor"
const val DEFAULT_LIMIT = 100
}
}

View file

@ -21,11 +21,6 @@ android {
}
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/** Project - Data */
@ -84,7 +79,5 @@ dependencies {
kapt(deps.hilt.kapt)
/** Test */
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
}

View file

@ -5,7 +5,6 @@
<ID>MaxChainedCallsOnSameLine:DefaultVisaRepository.kt$DefaultVisaRepository$userWallet.requireColdWallet().scanResponse.card.wallets.firstOrNull { it.curve == EllipticCurve.Secp256k1 }</ID>
<ID>MultilineLambdaItParameter:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository${ VisaDataToSignByCardWallet( request = request, hashToSign = it.result.hash, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository${ VisaDataToSignByCustomerWallet( request = request, hashToSign = it.result.hash, ) }</ID>
<ID>MultilineLambdaItParameter:VisaApiRequestMaker.kt$VisaApiRequestMaker${ if (it is ApiResponseError.HttpException &amp;&amp; it.code == ApiResponseError.HttpException.Code.UNAUTHORIZED ) { userWalletsStore.update(userWalletId) { userWallet -&gt; userWallet.requireColdWallet().copy( scanResponse = userWallet.scanResponse.copy( // visaCardActivationStatus = VisaCardActivationStatus.RefreshTokenExpired, ), ) } } throw RefreshTokenExpiredException() }</ID>
<ID>MultilineLambdaItParameter:VisaTxDetailsFactory.kt$VisaTxDetailsFactory${ when (val txUrl = walletBlockchain.getExploreTxUrl(it)) { is TxExploreState.Url -&gt; txUrl.url is TxExploreState.Unsupported -&gt; "" } }</ID>
<ID>MultilineLambdaItParameter:VisaTxHistoryPagingSource.kt$VisaTxHistoryPagingSource${ it.toMutableMap().apply { this[cardPublicKey] = this[cardPublicKey].orEmpty() + response.transactions } }</ID>
<ID>MultilineLambdaItParameter:VisaTxHistoryPagingSource.kt$VisaTxHistoryPagingSource${ it.toMutableMap().apply { this[offset] = response.transactions.map(VisaTxHistoryItemConverter::convert) } }</ID>
@ -13,8 +12,6 @@
<ID>NoNameShadowing:DefaultVisaActivationRepository.kt$DefaultVisaActivationRepository$responseError</ID>
<ID>NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (config != null) return@withLock requireNotNull(config)</ID>
<ID>NullCheckOnMutableProperty:VisaLibLoader.kt$VisaLibLoader$if (provider != null) return@withLock requireNotNull(provider)</ID>
<ID>NullableToStringCall:DefaultOnboardingRepository.kt$DefaultOnboardingRepository$${error.message}</ID>
<ID>RedundantSuspendModifier:DefaultVisaRepository.kt$DefaultVisaRepository$suspend</ID>
<ID>SuspendFunSwallowedCancellation:DefaultVisaRepository.kt$DefaultVisaRepository$runCatching</ID>
<ID>SuspendFunSwallowedCancellation:VisaApiRequestMaker.kt$VisaApiRequestMaker$runCatching</ID>
<ID>UnreachableCode:VisaApiRequestMaker.kt$VisaApiRequestMaker$if (status is VisaCardActivationStatus.RefreshTokenExpired) { throw RefreshTokenExpiredException() }</ID>

View file

@ -5,10 +5,7 @@ import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.CardDisplayName
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardLimit
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardLimitPeriod
import com.tangem.domain.models.pay.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
import javax.inject.Inject
@ -37,22 +34,24 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard()
is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount(
customerId = value.customerId,
currencyCode = value.currencyCode,
currencyCode = value.balance.fiatBalance.currency,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
availableForWithdrawal = value.availableForWithdrawal,
fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.balance.availableForWithdrawal,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
PaymentAccountStatusValueDM.TangemPayCard(
id = card.id,
productInstanceId = card.productInstanceId,
cardStatus = card.cardStatus.name,
hasPinCode = card.hasPinCode,
displayName = card.displayName?.value,
actualDailyLimit = card.limit?.actualCardLimit?.amount,
adminDailyLimit = card.limit?.adminCardLimit?.amount,
isFrozen = card.isFrozen,
frozenState = card.frozenState.toString(),
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
state = card.state.toString(),
)
},
)
@ -61,9 +60,11 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValue.Empty -> PaymentAccountStatusValueDM.Empty()
is PaymentAccountStatusValue.Deactivated -> PaymentAccountStatusValueDM.DeactivatedAccount(
customerId = value.customerId,
fiatRate = value.fiatRate,
fiatBalance = value.fiatBalance.toDM(),
cryptoBalance = value.cryptoBalance.toDM(),
fiatBalance = value.balance.fiatBalance.toDM(),
cryptoBalance = value.balance.cryptoBalance.toDM(),
availableForWithdrawal = value.balance.availableForWithdrawal,
)
// Transient statuses are not persisted
is PaymentAccountStatusValue.Loading,
@ -88,16 +89,19 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded(
source = StatusSource.CACHE,
customerId = value.customerId,
currencyCode = value.currencyCode,
depositAddress = value.depositAddress,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
cards = value.cards.map { card ->
TangemPayCard(
id = card.id,
productInstanceId = card.productInstanceId,
cardStatus = TangemPayCard.Status.fromString(card.cardStatus),
hasPinCode = card.hasPinCode,
displayName = card.displayName?.let { CardDisplayName(it).getOrElse { null } },
limit = TangemPayCardLimitData(
@ -108,11 +112,12 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
TangemPayCardLimit(limit, TangemPayCardLimitPeriod.DAY)
},
),
isFrozen = card.isFrozen,
frozenState = TangemPayCardFrozenState.fromString(card.frozenState),
lastDigits = card.lastDigits,
isReissuing = card.isReissuing,
state = TangemPayCardState.fromString(card.state),
)
},
error = null,
)
is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview(
source = StatusSource.CACHE,
@ -121,10 +126,15 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor(
)
is PaymentAccountStatusValueDM.DeactivatedAccount -> PaymentAccountStatusValue.Deactivated(
source = StatusSource.CACHE,
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
customerId = value.customerId,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = value.fiatBalance.toDomain(),
cryptoBalance = value.cryptoBalance.toDomain(),
availableForWithdrawal = value.availableForWithdrawal,
),
cryptoCurrency = cryptoCurrency,
fiatRate = value.fiatRate,
error = null,
)
null -> PaymentAccountStatusValue.Error.Unavailable
}

View file

@ -15,6 +15,7 @@ import com.tangem.data.pay.store.PaymentAccountStatusesStore
import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase
import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawUseCase
import com.tangem.data.pay.usecase.DefaultTangemPayWithdrawWithSwapUseCase
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM
@ -30,6 +31,7 @@ import com.tangem.domain.pay.usecase.*
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -61,10 +63,18 @@ internal interface TangemPayDataModule {
@Singleton
fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository
@Binds
@Singleton
fun bindCustomerOffersRepository(repository: DefaultCustomerOffersRepository): CustomerOffersRepository
@Binds
@Singleton
fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository
@Binds
@Singleton
fun bindCloseCardRepository(repository: DefaultCloseCardRepository): TangemPayCloseCardRepository
@Binds
@Singleton
fun bindTangemPayCryptoCurrencyFactory(factory: DefaultTangemPayCurrencyFactory): TangemPayCurrencyFactory
@ -75,6 +85,12 @@ internal interface TangemPayDataModule {
impl: DefaultGetTangemPayCurrencyStatusUseCase,
): GetTangemPayCurrencyStatusUseCase
@Binds
@Singleton
fun bindTangemPayWithdrawWithSwapUseCase(
impl: DefaultTangemPayWithdrawWithSwapUseCase,
): TangemPayWithdrawWithSwapUseCase
@Binds
@Singleton
fun bindTangemPayWithdrawUseCase(impl: DefaultTangemPayWithdrawUseCase): TangemPayWithdrawUseCase
@ -204,5 +220,57 @@ internal interface TangemPayDataModule {
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
@Provides
fun provideCloseTangemPayCardUseCase(
closeCardRepository: TangemPayCloseCardRepository,
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
appCoroutineScope: AppCoroutineScope,
paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
): CloseTangemPayCardUseCase {
return CloseTangemPayCardUseCase(
closeCardRepository = closeCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = appCoroutineScope,
paymentAccountStatusFetcher = paymentAccountStatusFetcher,
)
}
@Provides
fun provideGetCustomerOffersUseCase(
customerOffersRepository: CustomerOffersRepository,
): GetCustomerOffersUseCase {
return GetCustomerOffersUseCase(customerOffersRepository)
}
@Provides
fun provideCheckOrderConflictUseCase(
customerOrderRepository: CustomerOrderRepository,
): CheckOrderConflictUseCase {
return CheckOrderConflictUseCase(customerOrderRepository)
}
@Provides
fun provideRestoreActiveOrdersUseCase(
customerOrderRepository: CustomerOrderRepository,
): RestoreActiveOrdersUseCase {
return RestoreActiveOrdersUseCase(customerOrderRepository)
}
@Provides
fun provideValidateLocalOrderHintUseCase(
customerOrderRepository: CustomerOrderRepository,
onboardingRepository: OnboardingRepository,
): ValidateLocalOrderHintUseCase {
return ValidateLocalOrderHintUseCase(customerOrderRepository, onboardingRepository)
}
@Provides
fun provideIssueAdditionalCardUseCase(
customerOffersRepository: CustomerOffersRepository,
customerOrderRepository: CustomerOrderRepository,
): IssueAdditionalCardUseCase {
return IssueAdditionalCardUseCase(customerOffersRepository, customerOrderRepository)
}
}
}

View file

@ -7,9 +7,12 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.hasAccountData
import com.tangem.domain.models.kyc.KycStatus
import com.tangem.domain.models.pay.TangemPayCard
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardLimitData
import com.tangem.domain.models.pay.TangemPayCardState
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayCurrencyFactory
@ -19,16 +22,14 @@ import com.tangem.domain.pay.model.CustomerInfo
import com.tangem.domain.pay.model.OrderData
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.repository.TangemPayReissueCardRepository
import com.tangem.domain.pay.repository.*
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.security.DeviceSecurityInfoProvider
import com.tangem.security.isSecurityExposed
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
@ -39,7 +40,7 @@ import kotlin.time.Duration.Companion.minutes
private const val TAG = "PaymentAccountStatusFetcher"
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val paymentAccountStatusesStore: PaymentAccountStatusesStore,
private val onboardingRepository: OnboardingRepository,
@ -50,6 +51,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private val eligibilityManager: TangemPayEligibilityManager,
private val reissueCardRepository: TangemPayReissueCardRepository,
private val singleQuoteSupplier: SingleQuoteStatusSupplier,
private val closeCardRepository: TangemPayCloseCardRepository,
private val cardDetailsRepository: TangemPayCardDetailsRepository,
) : PaymentAccountStatusFetcher {
private val logger = TangemLogger.withTag(TAG)
@ -149,8 +152,19 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
private suspend fun proceedWithoutOrder(account: Account.Payment): PaymentAccountStatusValue {
return onboardingRepository.getCustomerInfo(account.userWalletId).fold(
ifLeft = { error ->
logger.e("proceedWithoutOrder ${account.userWalletId} error: $error")
error.mapToPaymentAccountStatus(account.userWalletId)
val cache = paymentAccountStatusesStore.getSyncOrNull(account.userWalletId)
if (cache != null && cache.value.hasAccountData()) {
cache.value.copySealed(
source = StatusSource.ONLY_CACHE,
error = when (error) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced
else -> PaymentAccountStatusValue.Error.Unavailable
},
)
} else {
logger.e("proceedWithoutOrder ${account.userWalletId} error: $error")
error.mapToPaymentAccountStatus(account.userWalletId)
}
},
ifRight = { customerInfo ->
logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}")
@ -265,8 +279,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
val quotesData = singleQuoteSupplier.getSyncOrNull(
params = SingleQuoteStatusProducer.Params(rawCurrencyId = TangemPayCurrencyFactory.TOKEN_ID),
)?.value as? QuoteStatus.Data
val cardInfo = this.cardInfo
val productInstance = this.productInstance
val isDeactivated = productInstance?.status == CustomerInfo.ProductInstance.Status.DEACTIVATED
val isFormer = state == CustomerInfo.State.FORMER
@ -281,19 +293,26 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
}
fiatBalance != null && cryptoBalance != null && (isDeactivated || isFormer) -> {
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() &&
(isDeactivated || isFormer) -> {
PaymentAccountStatusValue.Deactivated(
source = StatusSource.ACTUAL,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = availableForWithdrawal.orZero(),
),
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = quotesData?.fiatRate,
error = null,
)
}
cardInfo != null && productInstance != null && !customerId.isNullOrEmpty() -> convertToContentState(
cards.isNotEmpty() && productInstances.isNotEmpty() &&
fiatBalance != null && cryptoBalance != null && !customerId.isNullOrEmpty() -> convertToContentState(
userWalletId = userWalletId,
productInstance = productInstance,
cardInfo = cardInfo,
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
fiatRate = quotesData?.fiatRate,
customerId = requireNotNull(customerId) { "CustomerId must not be null" },
)
@ -301,50 +320,85 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor(
}
}
private suspend fun convertToContentState(
/**
* Builds the [PaymentAccountStatusValue.Loaded] content state with the full list of cards.
* Each card is the join of a product instance with its card info by `cardId`; balances are
* payment-account-level (shared across cards). Falls back to [PaymentAccountStatusValue.IssuingCard]
* when no card has both a product instance and card info yet (e.g. issuance in progress).
*/
private suspend fun CustomerInfo.convertToContentState(
userWalletId: UserWalletId,
productInstance: CustomerInfo.ProductInstance,
cardInfo: CustomerInfo.CardInfo,
fiatBalance: PaymentAccountStatusValue.FiatBalance,
cryptoBalance: PaymentAccountStatusValue.CryptoBalance,
customerId: String,
fiatRate: BigDecimal?,
): PaymentAccountStatusValue {
val reissueOrder = reissueCardRepository.getReissueOrderInfo(
userWalletId = userWalletId,
cardId = productInstance.cardId,
).getOrNull()
val cardsById = cards.associateBy { it.cardId }
val tangemPayCards = productInstances.mapNotNull { productInstance ->
val cardInfo = cardsById[productInstance.cardId] ?: return@mapNotNull null
val cardId = productInstance.cardId
val cardFrozenState = cardDetailsRepository.cardFrozenStateSync(cardId)
TangemPayCard(
id = cardId,
productInstanceId = productInstance.id,
cardStatus = cardInfo.cardStatus,
hasPinCode = cardInfo.isPinSet,
displayName = productInstance.displayName,
limit = TangemPayCardLimitData(
actualCardLimit = productInstance.actualCardLimit,
adminCardLimit = productInstance.adminCardLimit,
),
frozenState = if (cardFrozenState == TangemPayCardFrozenState.Pending) {
TangemPayCardFrozenState.Pending
} else {
productInstance.frozenState
},
lastDigits = cardInfo.lastFourDigits,
state = getCardState(cardId, userWalletId),
)
}
val isReissuing = reissueOrder != null &&
reissueOrder.orderStatus != OrderStatus.CANCELED &&
reissueOrder.orderStatus != OrderStatus.COMPLETED
if (tangemPayCards.isEmpty()) return PaymentAccountStatusValue.IssuingCard(source = StatusSource.ACTUAL)
val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId)
return PaymentAccountStatusValue.Loaded(
source = StatusSource.ACTUAL,
customerId = customerId,
currencyCode = cardInfo.currencyCode,
depositAddress = cardInfo.depositAddress,
fiatBalance = cardInfo.fiatBalance,
cryptoBalance = cardInfo.cryptoBalance,
availableForWithdrawal = cardInfo.availableForWithdrawal,
cryptoCurrency = cryptoCurrency,
depositAddress = cryptoBalance.depositAddress,
cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId),
fiatRate = fiatRate,
cards = listOf(
TangemPayCard(
id = productInstance.cardId,
hasPinCode = cardInfo.isPinSet,
displayName = productInstance.displayName,
limit = TangemPayCardLimitData(
actualCardLimit = productInstance.actualCardLimit,
adminCardLimit = productInstance.adminCardLimit,
),
isFrozen = productInstance.frozenState is TangemPayCardFrozenState.Frozen,
lastDigits = cardInfo.lastFourDigits,
isReissuing = isReissuing,
),
cards = tangemPayCards,
balance = PaymentAccountStatusValue.Balance(
fiatBalance = fiatBalance,
cryptoBalance = cryptoBalance,
availableForWithdrawal = availableForWithdrawal.orZero(),
),
error = null,
)
}
private suspend fun getCardState(cardId: String, userWalletId: UserWalletId): TangemPayCardState {
val closingOrderId = closeCardRepository.getCloseOrderId(userWalletId, cardId).getOrNull()
val reissueOrderId = reissueCardRepository.getReissueOrderId(userWalletId, cardId).getOrNull()
return if (closingOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, closingOrderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
closeCardRepository.setCloseOrderId(cardId, null)
TangemPayCardState.Active
} else {
TangemPayCardState.Closing
}
} else if (reissueOrderId != null) {
val order = cardDetailsRepository.getOrderInfo(userWalletId, reissueOrderId).getOrNull()
if (order != null && order.orderStatus.isTerminal) {
TangemPayCardState.Active
} else {
TangemPayCardState.Reissuing
}
} else {
TangemPayCardState.Active
}
}
private suspend fun VisaApiError.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue {
return when (this) {
is VisaApiError.RefreshTokenExpired -> PaymentAccountStatusValue.Error.NotSynced

View file

@ -0,0 +1,52 @@
package com.tangem.data.pay.repository
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.right
import com.tangem.core.error.UniversalError
import com.tangem.data.pay.util.OrderStatusConverter
import com.tangem.datasource.api.pay.TangemPayApi
import com.tangem.datasource.api.pay.models.request.CloseCardRequest
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCloseCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
internal class DefaultCloseCardRepository @Inject constructor(
private val tangemPayApi: TangemPayApi,
private val requestHelper: TangemPayRequestPerformer,
private val tangemPayCloseCardStore: TangemPayCloseCardStore,
) : TangemPayCloseCardRepository {
override suspend fun closeCard(
userWalletId: UserWalletId,
cardId: String,
): Either<VisaApiError, TangemPayOrderInfo> = either {
val response = requestHelper.performRequest(userWalletId) { authHeader ->
tangemPayApi.closeCard(
authHeader = authHeader,
body = CloseCardRequest(cardId = cardId),
)
}.bind()
TangemPayOrderInfo(
orderId = response.result.orderId,
orderStatus = OrderStatusConverter.convert(response.result.status),
)
}
override suspend fun setCloseOrderId(cardId: String, orderId: String?): Either<UniversalError, Unit> =
runSuspendCatching {
tangemPayCloseCardStore.setCloseOrderId(cardId, orderId)
}.fold(
onSuccess = { Unit.right() },
onFailure = { Either.Left(VisaApiError.Unspecified) },
)
override suspend fun getCloseOrderId(userWalletId: UserWalletId, cardId: String): Either<UniversalError, String?> =
either {
runSuspendCatching { tangemPayCloseCardStore.getOrderId(cardId) }.getOrNull()
}
}

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