Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-29 22:23:18 +04:00
parent a1bce4b52c
commit 08521b6c11
12 changed files with 230 additions and 90 deletions

View file

@ -120,8 +120,8 @@ data class AccountList private constructor(
}
@Serializable
data class DuplicateAccountNames(val message: String) : Error {
override fun toString(): String = "$tag: Account list contains duplicate account names. $message"
data object DuplicateAccountNames : Error {
override fun toString(): String = "$tag: Account list contains duplicate account names"
}
}
@ -161,17 +161,11 @@ data class AccountList private constructor(
val uniqueAccountIdsCount = accounts.map { it.accountId.value }.distinct().size
ensure(accounts.size == uniqueAccountIdsCount) { Error.DuplicateAccountIds }
val defaultMainNameCount = accounts.count { it.accountName is AccountName.DefaultMain }
val customNames = accounts.mapNotNull { (it.accountName as? AccountName.Custom)?.value }
val uniqueCustomNameCount = customNames.distinct().size
ensure(defaultMainNameCount == 0 || defaultMainNameCount == 1) {
Error.DuplicateAccountNames("Only one account can have the default main name.")
}
ensure(customNames.size == uniqueCustomNameCount) {
Error.DuplicateAccountNames("Custom account names must be unique.")
Error.DuplicateAccountNames
}
AccountList(

View file

@ -1,15 +1,14 @@
package com.tangem.domain.account.usecase
import arrow.core.Either
import arrow.core.Option
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.models.account.*
import com.tangem.domain.models.wallet.UserWalletId
@ -17,11 +16,15 @@ import com.tangem.domain.models.wallet.UserWalletId
* Use case for adding a new crypto portfolio account
*
* @property crudRepository the repository used for performing CRUD operations on accounts
* @property singleAccountListFetcher fetches the list of accounts for a single user wallet
* @property mainAccountTokensMigration handles the migration of tokens from the main account to the new
*
[REDACTED_AUTHOR]
*/
class AddCryptoPortfolioUseCase(
private val crudRepository: AccountsCRUDRepository,
private val singleAccountListFetcher: SingleAccountListFetcher,
private val mainAccountTokensMigration: MainAccountTokensMigration,
) {
/**
@ -40,21 +43,29 @@ class AddCryptoPortfolioUseCase(
icon: CryptoPortfolioIcon,
derivationIndex: DerivationIndex,
): Either<Error, Account.CryptoPortfolio> = either {
val newAccount = createAccount(userWalletId, accountName, icon, derivationIndex)
fetchAccountList(userWalletId)
val accountList = getAccountList(userWalletId = userWalletId).getOrElse {
createNewAccountList(userWalletId = userWalletId)
}
val accountList = getAccountList(userWalletId = userWalletId)
val newAccount = createAccount(userWalletId, accountName, icon, derivationIndex)
val updatedAccounts = (accountList + newAccount).getOrElse {
raise(Error.AccountListRequirementsNotMet(it))
}
saveAccounts(updatedAccounts)
saveAccounts(accountList = updatedAccounts)
mainAccountTokensMigration.migrate(userWalletId, derivationIndex)
newAccount
}
private suspend fun Raise<Error>.fetchAccountList(userWalletId: UserWalletId) {
singleAccountListFetcher(params = SingleAccountListFetcher.Params(userWalletId)).onLeft {
raise(Error.DataOperationFailed(cause = it))
}
}
private fun createAccount(
userWalletId: UserWalletId,
accountName: AccountName,
@ -70,26 +81,14 @@ class AddCryptoPortfolioUseCase(
)
}
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): Option<AccountList> {
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
}
private fun Raise<Error>.createNewAccountList(userWalletId: UserWalletId): AccountList {
val userWallet = catch(
block = { crudRepository.getUserWallet(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
// TODO: [REDACTED_JIRA]
return AccountList.empty(
userWallet = userWallet,
cryptoCurrencies = emptySet(),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
.getOrElse {
raise(Error.DataOperationFailed(message = "Account list not found for wallet $userWalletId"))
}
}
private suspend fun Raise<Error>.saveAccounts(accountList: AccountList) {
@ -115,7 +114,8 @@ class AddCryptoPortfolioUseCase(
/** Error indicating that a data operation failed */
data class DataOperationFailed(val cause: Throwable) : Error {
override fun toString(): String = "Data operation failed: ${cause.message ?: "Unknown error"}"
constructor(message: String) : this(cause = IllegalStateException(message))
}
}
}

View file

@ -157,7 +157,7 @@ class AccountListTest {
createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 0),
createAccount(userWalletId = userWalletId, name = "Name", derivationIndex = 1),
),
expected = AccountList.Error.DuplicateAccountNames("Custom account names must be unique.").left(),
expected = AccountList.Error.DuplicateAccountNames.left(),
),
)
}

View file

@ -5,8 +5,11 @@ import arrow.core.left
import arrow.core.right
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase.Error
import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.account.utils.createAccounts
import com.tangem.domain.models.account.Account
@ -23,13 +26,20 @@ import org.junit.jupiter.api.TestInstance
class AddCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = AddCryptoPortfolioUseCase(crudRepository)
private val singleAccountListFetcher = mockk<SingleAccountListFetcher>()
private val mainAccountTokensMigration = mockk<MainAccountTokensMigration>()
private val useCase = AddCryptoPortfolioUseCase(
crudRepository = crudRepository,
singleAccountListFetcher = singleAccountListFetcher,
mainAccountTokensMigration = mainAccountTokensMigration,
)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
clearMocks(crudRepository, singleAccountListFetcher, mainAccountTokensMigration, userWallet)
every { userWallet.walletId } returns userWalletId
}
@ -41,7 +51,13 @@ class AddCryptoPortfolioUseCaseTest {
val accountList = AccountList.empty(userWallet)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
coEvery {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
} returns Unit.right()
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
coEvery {
mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex)
} returns Unit.right()
// Act
val actual = useCase(
@ -55,22 +71,23 @@ class AddCryptoPortfolioUseCaseTest {
val expected = newAccount.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
crudRepository.getAccountListSync(userWalletId)
crudRepository.saveAccounts(updatedAccountList)
mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex)
}
coVerify(inverse = true) { crudRepository.getUserWallet(userWalletId) }
}
@Test
fun `invoke should create new account list if none exists`() = runTest {
fun `invoke should return error if fetch is failed`() = runTest {
// Arrange
val newAccount = createNewAccount()
val newAccountList = (AccountList.empty(userWallet) + newAccount).getOrNull()!!
val exception = Exception("Fetch error")
coEvery { crudRepository.getAccountListSync(userWalletId) } returns None
coEvery { crudRepository.getUserWallet(userWalletId) } returns userWallet
coEvery {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
} returns exception.left()
// Act
val actual = useCase(
@ -81,13 +98,54 @@ class AddCryptoPortfolioUseCaseTest {
)
// Assert
val expected = newAccount.right()
Truth.assertThat(actual).isEqualTo(expected)
val expected = exception
Truth.assertThat((actual.leftOrNull() as Error.DataOperationFailed).cause).isInstanceOf(expected::class.java)
Truth.assertThat((actual.leftOrNull() as Error.DataOperationFailed).cause).hasMessageThat()
.isEqualTo(expected.message)
coVerifyOrder {
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
}
coVerify(inverse = true) {
crudRepository.getAccountListSync(any())
crudRepository.saveAccounts(any())
mainAccountTokensMigration.migrate(any(), any())
}
}
@Test
fun `invoke should return error if account list none exists`() = runTest {
// Arrange
val newAccount = createNewAccount()
coEvery {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
} returns Unit.right()
coEvery { crudRepository.getAccountListSync(userWalletId) } returns None
// Act
val actual = useCase(
userWalletId = userWalletId,
accountName = newAccount.accountName,
icon = newAccount.icon,
derivationIndex = newAccount.derivationIndex,
)
// Assert
val expected = IllegalStateException("Accounts for $userWalletId are not created")
Truth.assertThat((actual.leftOrNull() as Error.DataOperationFailed).cause).isInstanceOf(expected::class.java)
Truth.assertThat((actual.leftOrNull() as Error.DataOperationFailed).cause).hasMessageThat()
.isEqualTo(expected.message)
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
crudRepository.getAccountListSync(userWalletId)
crudRepository.getUserWallet(userWalletId)
crudRepository.saveAccounts(newAccountList)
}
coVerify(inverse = true) {
crudRepository.saveAccounts(any())
mainAccountTokensMigration.migrate(any(), any())
}
}
@ -102,6 +160,9 @@ class AddCryptoPortfolioUseCaseTest {
val newAccount = createNewAccount(derivationIndex = 21)
coEvery {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
} returns Unit.right()
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
// Act
@ -113,17 +174,20 @@ class AddCryptoPortfolioUseCaseTest {
)
// Assert
val expected = AddCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet(
val expected = Error.AccountListRequirementsNotMet(
cause = AccountList.Error.ExceedsMaxAccountsCount,
).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId) }
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
crudRepository.getAccountListSync(userWalletId)
}
coVerify(inverse = true) {
crudRepository.getUserWallet(any())
crudRepository.saveAccounts(any())
mainAccountTokensMigration.migrate(any(), any())
}
}
@ -133,6 +197,9 @@ class AddCryptoPortfolioUseCaseTest {
val newAccount = createNewAccount()
val exception = IllegalStateException("Test error")
coEvery {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
} returns Unit.right()
coEvery { crudRepository.getAccountListSync(userWalletId) } throws exception
// Act
@ -144,14 +211,17 @@ class AddCryptoPortfolioUseCaseTest {
)
// Assert
val expected = AddCryptoPortfolioUseCase.Error.DataOperationFailed(cause = exception).left()
val expected = Error.DataOperationFailed(cause = exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId) }
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
crudRepository.getAccountListSync(userWalletId)
}
coVerify(inverse = true) {
crudRepository.getUserWallet(any())
crudRepository.saveAccounts(any())
mainAccountTokensMigration.migrate(any(), any())
}
}
@ -164,6 +234,9 @@ class AddCryptoPortfolioUseCaseTest {
val exception = IllegalStateException("Test error")
coEvery {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
} returns Unit.right()
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception
@ -176,15 +249,54 @@ class AddCryptoPortfolioUseCaseTest {
)
// Assert
val expected = AddCryptoPortfolioUseCase.Error.DataOperationFailed(cause = exception).left()
val expected = Error.DataOperationFailed(cause = exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
crudRepository.getAccountListSync(userWalletId)
crudRepository.saveAccounts(updatedAccountList)
}
coVerify(inverse = true) { crudRepository.getUserWallet(userWalletId) }
coVerify(inverse = true) {
mainAccountTokensMigration.migrate(any(), any())
}
}
@Test
fun `invoke should return new account if migrate returns error`() = runTest {
// Arrange
val newAccount = createNewAccount()
val accountList = AccountList.empty(userWallet)
val updatedAccountList = (accountList + newAccount).getOrNull()!!
val exception = Exception("Migration error")
coEvery {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
} returns Unit.right()
coEvery { crudRepository.getAccountListSync(userWalletId) } returns accountList.toOption()
coEvery {
mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex)
} returns exception.left()
// Act
val actual = useCase(
userWalletId = userWalletId,
accountName = newAccount.accountName,
icon = newAccount.icon,
derivationIndex = newAccount.derivationIndex,
)
// Assert
val expected = newAccount.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))
crudRepository.getAccountListSync(userWalletId)
crudRepository.saveAccounts(updatedAccountList)
mainAccountTokensMigration.migrate(userWalletId, newAccount.derivationIndex)
}
}
private companion object {