Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-29 12:59:18 +04:00
parent 3b65bef8a8
commit 75cdf4011c
5 changed files with 479 additions and 7 deletions

View file

@ -1,6 +1,8 @@
package com.tangem.common.test.utils
import arrow.core.Either
import arrow.core.None
import arrow.core.Option
import com.google.common.truth.Truth
fun <B> assertEither(actual: Either<Throwable, B>, expected: Either<Throwable, B>) {
@ -29,4 +31,16 @@ fun <B> assertEitherLeft(actual: Either<Throwable, B>, expected: Throwable) {
Truth.assertThat(it::class.java).isEqualTo(expected::class.java)
Truth.assertThat(it).hasMessageThat().isEqualTo(expected.message)
}
}
fun <B> assertNone(actual: Option<B>) {
Truth.assertThat(actual).isEqualTo(None)
}
fun <B> assertSome(actual: Option<B>, expected: B) {
actual
.onNone { error("Actual is None") }
.onSome {
Truth.assertThat(it).isEqualTo(expected)
}
}

View file

@ -3,6 +3,7 @@ plugins {
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
@ -15,18 +16,20 @@ tasks.withType<Test>().configureEach {
}
dependencies {
implementation(projects.domain.account)
implementation(projects.domain.core)
api(projects.domain.account)
api(projects.domain.core)
api(projects.domain.common)
implementation(projects.domain.quotes)
implementation(projects.domain.models)
implementation(projects.domain.networks)
implementation(projects.domain.staking)
implementation(projects.domain.tokens)
api(projects.domain.quotes)
api(projects.domain.models)
api(projects.domain.networks)
api(projects.domain.staking)
api(projects.domain.tokens)
implementation(projects.libs.crypto)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
implementation(deps.timber)
// region DI
implementation(deps.hilt.android)

View file

@ -0,0 +1,20 @@
package com.tangem.domain.account.status.model
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.serialization.Serializable
/**
* Combines an [Account] with its corresponding [CryptoCurrency].
*
* This data class is useful for representing the relationship between an account and the cryptocurrency it holds.
*
[REDACTED_AUTHOR]
*/
@Serializable
data class AccountCryptoCurrency(val account: Account.CryptoPortfolio, val cryptoCurrency: CryptoCurrency) {
init {
require(account.cryptoCurrencies.contains(cryptoCurrency))
}
}

View file

@ -0,0 +1,136 @@
package com.tangem.domain.account.status.usecase
import arrow.core.NonEmptyList
import arrow.core.None
import arrow.core.Option
import arrow.core.raise.OptionRaise
import arrow.core.raise.RaiseDSL
import arrow.core.raise.option
import arrow.core.toNonEmptyListOrNull
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.status.model.AccountCryptoCurrency
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.getAddress
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.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import timber.log.Timber
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
private typealias WalletIdWithNetworkId = Pair<UserWalletId, Network.ID>
/**
* Use case to retrieve an [AccountCryptoCurrency] based on a provided address.
*
* @property userWalletsListRepository Repository to access user wallets.
* @property multiNetworkStatusSupplier Supplier to get network status for multiple networks.
* @property singleAccountListSupplier Supplier to get account lists for a single wallet.
*
[REDACTED_AUTHOR]
*/
class GetAccountCurrencyByAddressUseCase(
private val userWalletsListRepository: UserWalletsListRepository,
private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier,
private val singleAccountListSupplier: SingleAccountListSupplier,
) {
/**
* Invokes the use case to get an [AccountCryptoCurrency] for the given address.
*
* @param address The address to look up.
* @return An [Option] containing the [AccountCryptoCurrency] if found, or [None] if not found or if any validation
* fails.
*/
suspend operator fun invoke(address: String): Option<AccountCryptoCurrency> = option {
validate(address)
val userWalletIds = getUserWalletIds()
val (walletId, networkId) = getWalletIdWithNetworkId(userWalletIds, address)
val accountList = getAccountList(userWalletId = walletId)
getAccountCryptoCurrency(accountList, networkId)
}
private fun OptionRaise.validate(address: String) {
ensureNotNull(address.takeIf { it.isNotEmpty() }) { "Address is empty" }
}
private fun OptionRaise.getUserWalletIds(): NonEmptyList<UserWalletId> {
val userWalletIds = userWalletsListRepository.userWallets.value.orEmpty()
.filter(UserWallet::isMultiCurrency)
.map(UserWallet::walletId)
.toNonEmptyListOrNull()
return ensureNotNull(userWalletIds) { "No multi-currency wallets found" }
}
private suspend fun OptionRaise.getWalletIdWithNetworkId(
userWalletIds: NonEmptyList<UserWalletId>,
address: String,
): WalletIdWithNetworkId {
var pair: WalletIdWithNetworkId? = null
for (id in userWalletIds) {
val networkStatus = multiNetworkStatusSupplier.getSyncOrNull(
params = MultiNetworkStatusProducer.Params(userWalletId = id),
)
?.firstOrNull { it.getAddress() == address }
if (networkStatus != null) {
pair = id to networkStatus.network.id
break
}
}
return ensureNotNull(value = pair) { "No network status found for address: $address" }
}
private suspend fun OptionRaise.getAccountList(userWalletId: UserWalletId): AccountList {
val accountList = singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
return ensureNotNull(accountList) { "No account list found for walletId: $userWalletId" }
}
private fun OptionRaise.getAccountCryptoCurrency(
accountList: AccountList,
networkId: Network.ID,
): AccountCryptoCurrency {
val result = accountList.accounts.asSequence()
.filterIsInstance<Account.CryptoPortfolio>()
.mapNotNull { account ->
val currency = account.cryptoCurrencies.firstOrNull { it.network.id == networkId }
?: return@mapNotNull null
AccountCryptoCurrency(account = account, cryptoCurrency = currency)
}
.firstOrNull()
return ensureNotNull(result) {
"No account found for network: $networkId in walletId: ${accountList.userWallet.walletId}"
}
}
@OptIn(ExperimentalContracts::class)
@RaiseDSL
inline fun <B : Any> OptionRaise.ensureNotNull(value: B?, message: () -> String): B {
contract {
returns() implies (value != null)
}
return value ?: run {
Timber.d(message())
raise(None)
}
}
}

View file

@ -0,0 +1,299 @@
package com.tangem.domain.account.status.usecase
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.assertNone
import com.tangem.common.test.utils.assertSome
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.status.model.AccountCryptoCurrency
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
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.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetAccountCurrencyByAddressUseCaseTest {
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val multiNetworkStatusSupplier: MultiNetworkStatusSupplier = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
private val useCase = GetAccountCurrencyByAddressUseCase(
userWalletsListRepository = userWalletsListRepository,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
singleAccountListSupplier = singleAccountListSupplier,
)
@AfterEach
fun tearDown() {
clearMocks(userWalletsListRepository, multiNetworkStatusSupplier, singleAccountListSupplier)
}
@Test
fun `returns None if address is empty`() = runTest {
// Arrange
val address = ""
// Act
val actual = useCase(address)
// Assert
assertNone(actual)
}
@Test
fun `returns None if userWalletIds is null`() = runTest {
// Arrange
every { userWalletsListRepository.userWallets } returns MutableStateFlow(null)
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
}
}
@Test
fun `returns None if userWalletIds is empty list`() = runTest {
// Arrange
every { userWalletsListRepository.userWallets } returns MutableStateFlow(emptyList())
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
}
}
@Test
fun `returns None if userWalletIds contain only single wallet`() = runTest {
// Arrange
val singleWallet = mockk<UserWallet> {
every { isMultiCurrency } returns false
}
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(singleWallet))
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
}
}
@Test
fun `returns None if multiNetworkStatusSupplier returns null`() = runTest {
// Arrange
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
} returns null
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
}
}
@Test
fun `returns None if multiNetworkStatusSupplier returns empty list`() = runTest {
// Arrange
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
} returns emptySet()
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
}
}
@Test
fun `returns None if network status not found`() = runTest {
// Arrange
val networkStatus = NetworkStatus(
network = mockk(),
value = NetworkStatus.Unreachable(address = null),
)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
} returns setOf(networkStatus)
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
}
}
@Test
fun `returns None if singleAccountListSupplier returns null`() = runTest {
// Arrange
val networkStatus = NetworkStatus(
network = mockk {
every { id } returns Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None)
},
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
} returns setOf(networkStatus)
coEvery {
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
} returns null
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
}
}
@Test
fun `returns None if crypto currencies is empty list`() = runTest {
// Arrange
val networkId = Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None)
val networkStatus = NetworkStatus(
network = mockk {
every { id } returns networkId
},
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
val accountList = AccountList.empty(multiUserWallet)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
} returns setOf(networkStatus)
coEvery {
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
} returns accountList
// Act
val actual = useCase(validAddress)
// Assert
assertNone(actual)
coVerifySequence {
userWalletsListRepository.userWallets
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
}
}
@Test
fun `returns Some if all data is valid`() = runTest {
// Arrange
val currency = MockCryptoCurrencyFactory().ethereum
val networkStatus = NetworkStatus(
network = currency.network,
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
val accountList = AccountList.empty(userWallet = multiUserWallet, cryptoCurrencies = setOf(currency))
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(multiUserWallet))
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
} returns setOf(networkStatus)
coEvery {
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
} returns accountList
// Act
val actual = useCase(validAddress)
// Assert
val expected = AccountCryptoCurrency(account = accountList.mainAccount, cryptoCurrency = currency)
assertSome(actual, expected)
coVerifySequence {
userWalletsListRepository.userWallets
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId))
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
}
}
private companion object Companion {
const val validAddress = "0x1234567890abcdef"
val validNetworkAddress = NetworkAddress.Single(
defaultAddress = NetworkAddress.Address(
value = validAddress,
type = NetworkAddress.Address.Type.Primary,
),
)
val userWalletId = UserWalletId("011")
val multiUserWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.isMultiCurrency } returns true
}
}
}