Updated on 2026-08-14

This commit is contained in:
Tangem 2025-07-01 13:30:53 +04:00
parent ea8695c031
commit 45cf551d3f
8 changed files with 511 additions and 1 deletions

View file

@ -50,7 +50,7 @@ internal class DefaultMultiNetworkStatusProducerTest {
@Test
fun `flow is mapped for user wallet id from params`() = runTest {
// Assert
// Arrange
val statuses = setOf(
MockNetworkStatusFactory.createVerified(ethNetwork),
MockNetworkStatusFactory.createVerified(cardanoNetwork),

View file

@ -12,6 +12,10 @@ android {
namespace = "com.tangem.data.tokens"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Data
@ -62,4 +66,13 @@ dependencies {
ksp(deps.moshi.kotlin.codegen)
kaptForObfuscatingVariants(deps.retrofit.response.type.keeper)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -0,0 +1,64 @@
package com.tangem.data.tokens
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.wallets.models.requireColdWallet
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.*
/**
* Default implementation of [MultiWalletCryptoCurrenciesProducer]
*
* @property params params
* @property userWalletsStore UserWallet's store
* @property userTokensResponseStore store of `UserTokensResponse`
* @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse`
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiWalletCryptoCurrenciesProducer @AssistedInject constructor(
@Assisted val params: MultiWalletCryptoCurrenciesProducer.Params,
private val userWalletsStore: UserWalletsStore,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesProducer {
override val fallback: Set<CryptoCurrency>
get() = emptySet()
override fun produce(): Flow<Set<CryptoCurrency>> {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId).requireColdWallet() // TODO [REDACTED_TASK_KEY]
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName} supports only multi-currency wallet")
}
return userTokensResponseStore.get(userWalletId = params.userWalletId)
.distinctUntilChanged()
.map { response ->
if (response == null) return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
response = response,
scanResponse = userWallet.scanResponse,
).toSet()
}
.onEmpty { emit(fallback) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiWalletCryptoCurrenciesProducer.Factory {
override fun create(
params: MultiWalletCryptoCurrenciesProducer.Params,
): DefaultMultiWalletCryptoCurrenciesProducer
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.data.tokens.di
import com.tangem.data.tokens.DefaultMultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface MultiWalletCryptoCurrenciesProducerModule {
@Singleton
@Binds
fun bindMultiWalletCryptoCurrenciesProducerFactory(
impl: DefaultMultiWalletCryptoCurrenciesProducer.Factory,
): MultiWalletCryptoCurrenciesProducer.Factory
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.tokens.di
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal class MultiWalletCryptoCurrenciesSupplierModule {
@Singleton
@Provides
fun provideMultiWalletCryptoCurrenciesSupplier(
factory: MultiWalletCryptoCurrenciesProducer.Factory,
): MultiWalletCryptoCurrenciesSupplier {
return object : MultiWalletCryptoCurrenciesSupplier(
factory = factory,
keyCreator = { "multi_crypto_currency_${it.userWalletId}" },
) {}
}
}

View file

@ -0,0 +1,351 @@
package com.tangem.data.tokens
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.configs.GenericCardConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.isMultiCurrency
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId = userWallet.walletId)
private val userWalletsStore: UserWalletsStore = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
private val producer = DefaultMultiWalletCryptoCurrenciesProducer(
params = params,
userWalletsStore = userWalletsStore,
userTokensResponseStore = userTokensResponseStore,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsStore, userTokensResponseStore, responseCryptoCurrenciesFactory)
}
@Test
fun `flow is mapped for user wallet id from params`() = runTest {
// Arrange
val userTokensResponseFlow = flowOf<UserTokensResponse?>(null)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = emptySet<CryptoCurrency>()
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any())
}
}
@Test
fun `flow will updated if UserTokensResponse is updated`() = runTest {
// Arrange
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
val updatedUserTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = listOf(
UserTokensResponse.Token(
id = null,
networkId = "bitcoin",
derivationPath = null,
name = "Bitcoin",
symbol = "BTC",
decimals = 8,
contractAddress = null,
addresses = listOf(),
),
),
)
val updatedCryptoCurrencies = setOf(
cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin),
)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns cryptoCurrencies.toList()
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns updatedCryptoCurrencies.toList()
val producerFlow = producer.produce()
// Act 1 (first emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = cryptoCurrencies
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
// Act 2 (second emit)
userTokensResponseFlow.emit(updatedUserTokensResponse)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = listOf(cryptoCurrencies, updatedCryptoCurrencies)
Truth.assertThat(actual2.size).isEqualTo(2)
Truth.assertThat(actual2).isEqualTo(expected2)
verifyOrder {
responseCryptoCurrenciesFactory.createCurrencies(
response = updatedUserTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
}
@Test
fun `flow is filtered the same status`() = runTest {
// Arrange
val userTokensResponseFlow = MutableSharedFlow<UserTokensResponse>(replay = 2)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns cryptoCurrencies.toList()
val producerFlow = producer.produce()
// Act 1 (first emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = cryptoCurrencies
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
// Act 2 (second emit)
userTokensResponseFlow.emit(userTokensResponse)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = expected1
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
}
@Test
fun `flow throws exception`() = runTest {
// Arrange
val exception = IllegalStateException()
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.TOKEN,
sort = UserTokensResponse.SortType.MARKETCAP,
tokens = emptyList(),
)
val cryptoCurrencies = emptySet<CryptoCurrency>()
val innerFlow = MutableStateFlow(value = false)
val userTokensResponseFlow = flow {
if (innerFlow.value) {
emit(userTokensResponse)
} else {
throw exception
}
}
.buffer(capacity = 5)
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns userTokensResponseFlow
every {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
} returns cryptoCurrencies.toList()
val producerFlow = producer.produceWithFallback()
// Act 1 (fallback)
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = producer.fallback
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
}
// Act 2 (emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = cryptoCurrencies
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
verifyOrder {
responseCryptoCurrenciesFactory.createCurrencies(
response = userTokensResponse,
scanResponse = userWallet.scanResponse,
)
}
}
@Test
fun `flow is empty if store returns empty flow`() = runTest {
// Arrange
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns userWallet
every { userTokensResponseStore.get(params.userWalletId) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = producer.fallback
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)
verifyOrder {
userWalletsStore.getSyncStrict(params.userWalletId)
userTokensResponseStore.get(params.userWalletId)
}
verify(inverse = true) {
responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any())
}
}
@Test
fun `produce throws exception if UserWallet isn't multi-currency wallet`() = runTest {
// Arrange
val mockUserWallet = mockk<UserWallet> {
every { isMultiCurrency } returns false
}
every { userWalletsStore.getSyncStrict(params.userWalletId) } returns mockUserWallet
// Act
val actual = runCatching { producer.produce() }.exceptionOrNull()
// Assert
val expected = IllegalStateException(
"${DefaultMultiWalletCryptoCurrenciesProducer::class.simpleName} supports only multi-currency wallet",
)
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder { userWalletsStore.getSyncStrict(params.userWalletId) }
verify(inverse = true) {
userTokensResponseStore.get(any())
responseCryptoCurrenciesFactory.createCurrencies(response = any(), scanResponse = any())
}
}
private companion object {
val scanResponse = MockScanResponseFactory.create(
cardConfig = GenericCardConfig(2),
derivedKeys = emptyMap(),
)
val userWallet = MockUserWalletFactory.create(scanResponse = scanResponse)
}
}