Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-05 19:19:52 +04:00
parent 231127ad4d
commit d369d87c2f
8 changed files with 565 additions and 32 deletions

View file

@ -16,8 +16,9 @@ dependencies {
implementation(deps.arrow.core)
implementation(deps.kotlin.serialization)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.truth)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
}

View file

@ -5,6 +5,7 @@ import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.extensions.addOrReplace
import kotlinx.serialization.Serializable
/**
@ -27,6 +28,42 @@ data class AccountList private constructor(
val mainAccount: Account.CryptoPortfolio
get() = accounts.first { it is Account.CryptoPortfolio && it.isMainAccount } as Account.CryptoPortfolio
/**
* Adds an account to the account list.
* If an account with the same identifier already exists, it will be replaced.
* Returns a new [AccountList] instance with the updated accounts set, or a validation error if constraints are
* violated (e.g., maximum number of accounts exceeded).
*
* @param other the account to add or replace
*/
operator fun plus(other: Account): Either<Error, AccountList> {
val isNewAccount = this.accounts.none { it.accountId == other.accountId }
val accounts = this.accounts.addOrReplace(other) { it.accountId == other.accountId }
return invoke(
userWallet = this.userWallet,
accounts = accounts,
totalAccounts = this.totalAccounts + if (isNewAccount) 1 else 0,
)
}
/**
* Removes the specified account from the account list.
* Returns a new [AccountList] instance with the updated accounts set, or a validation error if constraints are
* violated (e.g., the list becomes empty).
*
* @param other the account to remove
*/
operator fun minus(other: Account): Either<Error, AccountList> {
return invoke(
userWallet = this.userWallet,
accounts = this.accounts.toMutableSet().apply {
removeIf { it.accountId == other.accountId }
},
totalAccounts = this.totalAccounts - 1,
)
}
/**
* Represents possible errors that can occur when creating an `AccountList`
*/
@ -54,10 +91,23 @@ data class AccountList private constructor(
return "$tag: There should be at most one main crypto portfolio in the account list"
}
}
@Serializable
data object ExceedsMaxAccountsCount : Error {
override fun toString(): String = "$tag: The number of accounts must not exceed 20"
}
@Serializable
data object DuplicateAccountIds : Error {
override fun toString(): String = "$tag: Account list contains duplicate account IDs"
}
}
companion object {
private const val MAX_ACCOUNTS_COUNT = 20
private const val MAX_MAIN_ACCOUNTS_COUNT = 1
/**
* Factory method to create an `AccountList` instance.
* Validates the input to ensure the accounts list is not empty and contains exactly one main account.
@ -73,8 +123,10 @@ data class AccountList private constructor(
): Either<Error, AccountList> = either {
ensure(accounts.isNotEmpty()) { Error.EmptyAccountsList }
ensure(accounts.size <= MAX_ACCOUNTS_COUNT) { Error.ExceedsMaxAccountsCount }
val mainAccountsCount = accounts.mainAccountsCount()
ensure(mainAccountsCount == 1) {
ensure(mainAccountsCount == MAX_MAIN_ACCOUNTS_COUNT) {
if (mainAccountsCount == 0) {
Error.MainAccountNotFound
} else {
@ -82,6 +134,9 @@ data class AccountList private constructor(
}
}
val uniqueAccountIdsCount = accounts.map { it.accountId.value }.distinct().size
ensure(accounts.size == uniqueAccountIdsCount) { Error.DuplicateAccountIds }
AccountList(userWallet = userWallet, accounts = accounts, totalAccounts = totalAccounts)
}

View file

@ -1,50 +1,129 @@
package com.tangem.domain.account.usecase
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import arrow.core.raise.ensure
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlin.random.Random
import com.tangem.domain.models.wallet.UserWalletId
/**
* Use case for updating a crypto portfolio account.
*
* @property crudRepository the repository used for performing CRUD operations on accounts
*
[REDACTED_AUTHOR]
*/
class UpdateCryptoPortfolioUseCase {
class UpdateCryptoPortfolioUseCase(
private val crudRepository: AccountsCRUDRepository,
) {
/**
* Updates a crypto portfolio account with the provided name and/or icon
*
* @param accountId the unique identifier of the account to update
* @param accountName the new name for the account (optional)
* @param icon the new icon for the account (optional)
* @return an [Either] containing the updated [Account.CryptoPortfolio] on success, or an [Error] on failure
*/
suspend operator fun invoke(
accountId: AccountId,
name: AccountName? = null,
accountName: AccountName? = null,
icon: CryptoPortfolioIcon? = null,
): Either<Error, Account.CryptoPortfolio> = either {
Account.CryptoPortfolio(
accountId = accountId,
name = name?.value ?: "Account",
accountIcon = icon ?: CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = Random.nextInt(from = 0, until = 21),
isArchived = false,
cryptoCurrencyList = Account.CryptoPortfolio.CryptoCurrencyList(
currencies = emptySet(),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
)
.mapLeft { Error.DataOperationFailed }
.bind()
ensure(accountName != null || icon != null) { Error.NothingToUpdate }
// TODO: [REDACTED_JIRA]
// Create a domain model AccountName
// Get the current account by [accountId]
// Create a new domain model Account from old data considering new parameters
// Save information in local storage
// Save to backend
val accountList = getAccountList(userWalletId = accountId.userWalletId)
val account = accountList.accounts
.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio
?: raise(Error.CriticalTechError.AccountNotFound(accountId = accountId))
val updatedAccount = account
.setName(name = accountName)
.setIcon(icon = icon)
val updatedAccounts = (accountList + updatedAccount)
.getOrElse { raise(Error.CriticalTechError.AccountListRequirementsNotMet(it)) }
saveAccounts(updatedAccounts)
updatedAccount
}
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { crudRepository.getAccounts(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) }
}
private suspend fun Raise<Error>.saveAccounts(accountList: AccountList) {
catch(
block = { crudRepository.saveAccounts(accountList) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
}
private fun Account.CryptoPortfolio.setName(name: AccountName?): Account.CryptoPortfolio {
return if (name != null) this.copy(accountName = name) else this
}
private fun Account.CryptoPortfolio.setIcon(icon: CryptoPortfolioIcon?): Account.CryptoPortfolio {
return if (icon != null) this.copy(accountIcon = icon) else this
}
/**
* Represents possible errors that can occur during the update operation
*/
sealed interface Error {
data object DataOperationFailed : Error
/** Error indicating that there is nothing to update */
data object NothingToUpdate : Error {
override fun toString(): String = "Nothing to update: both account name and icon are null"
}
/** 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"}"
}
/**
* Represents critical technical errors that can occur during the update operation.
* These errors are a consequence of an inconsistent state.
*/
sealed interface CriticalTechError : Error {
/**
*
* @property userWalletId the unique identifier of the user wallet
*/
data class AccountsNotCreated(val userWalletId: UserWalletId) : CriticalTechError {
override fun toString(): String = "Accounts for $userWalletId are not created"
}
/** Error indicating that the account with [accountId] was not found */
data class AccountNotFound(val accountId: AccountId) : CriticalTechError {
override fun toString(): String = "Account with ID $accountId not found"
}
/**
* Error indicating that the account list requirements were not met.
*
* @property cause the underlying cause of the error
*/
data class AccountListRequirementsNotMet(val cause: AccountList.Error) : CriticalTechError {
override fun toString(): String = "Account list requirements not met: $cause"
}
}
}
}

View file

@ -3,7 +3,9 @@ package com.tangem.domain.account.models
import arrow.core.Either
import arrow.core.left
import com.google.common.truth.Truth
import com.tangem.domain.account.utils.randomAccountId
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import io.mockk.clearMocks
import io.mockk.every
@ -92,6 +94,33 @@ class AccountListTest {
),
)
},
createAccounts(count = 20).let {
CreateTestModel(
accounts = it,
expected = AccountList(
userWallet = userWallet,
accounts = it,
totalAccounts = 20,
),
)
},
CreateTestModel(
accounts = createAccounts(21),
expected = AccountList.Error.ExceedsMaxAccountsCount.left(),
),
CreateTestModel(
accounts = setOf(
createAccount(
accountId = AccountId(value = "1", userWalletId = mockk()),
isMain = true,
),
createAccount(
accountId = AccountId(value = "1", userWalletId = mockk()),
isMain = false,
),
),
expected = AccountList.Error.DuplicateAccountIds.left(),
),
)
}
@ -100,9 +129,22 @@ class AccountListTest {
val expected: Either<AccountList.Error, AccountList>,
)
private fun createAccount(isMain: Boolean = false): Account.CryptoPortfolio {
private fun createAccounts(count: Int): Set<Account.CryptoPortfolio> {
return buildSet {
add(createAccount(isMain = true))
repeat(count - 1) {
add(createAccount(isMain = false))
}
}
}
private fun createAccount(
accountId: AccountId = AccountId(value = randomAccountId(5), userWalletId = mockk()),
isMain: Boolean = false,
): Account.CryptoPortfolio {
return mockk<Account.CryptoPortfolio> {
every { isMainAccount } returns isMain
every { this@mockk.accountId } returns accountId
every { this@mockk.isMainAccount } returns isMain
}
}
}

View file

@ -0,0 +1,298 @@
package com.tangem.domain.account.usecase
import arrow.core.None
import arrow.core.left
import arrow.core.right
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase.Error
import com.tangem.domain.account.utils.randomAccountId
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import kotlin.random.Random
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UpdateCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = UpdateCryptoPortfolioUseCase(crudRepository = crudRepository)
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository)
}
@Test
fun `invoke should update crypto portfolio account with new name`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val account = createAccount(accountId = accountId, isMain = true)
val accountList = AccountList(
userWallet = mockk(),
accounts = setOf(account),
totalAccounts = 1,
)
.getOrNull()!!
val newAccountName = AccountName("New name").getOrNull()!!
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName)
// Assert
val updatedAccount = account.copy(accountName = newAccountName)
val expected = updatedAccount.right()
Truth.assertThat(actual).isEqualTo(expected)
val updatedAccountList = (accountList + updatedAccount).getOrNull()!!
coVerifyOrder {
crudRepository.getAccounts(userWalletId = userWalletId)
crudRepository.saveAccounts(accountList = updatedAccountList)
}
}
@Test
fun `invoke should update crypto portfolio account with new icon`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val account = createAccount(accountId = accountId, isMain = true)
val accountList = AccountList(
userWallet = mockk(),
accounts = setOf(account),
totalAccounts = 1,
)
.getOrNull()!!
val newAccountIcon = CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.CaribbeanBlue,
)
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId = accountId, icon = newAccountIcon)
// Assert
val updatedAccount = account.copy(accountIcon = newAccountIcon)
val expected = updatedAccount.right()
Truth.assertThat(actual).isEqualTo(expected)
val updatedAccountList = (accountList + updatedAccount).getOrNull()!!
coVerifyOrder {
crudRepository.getAccounts(userWalletId = userWalletId)
crudRepository.saveAccounts(accountList = updatedAccountList)
}
}
@Test
fun `invoke should update crypto portfolio account with new name and icon`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val account = createAccount(accountId = accountId, isMain = true)
val accountList = AccountList(
userWallet = mockk(),
accounts = setOf(account),
totalAccounts = 1,
)
.getOrNull()!!
val newAccountName = AccountName("New name").getOrNull()!!
val newAccountIcon = CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.CaribbeanBlue,
)
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName, icon = newAccountIcon)
// Assert
val updatedAccount = account.copy(accountName = newAccountName, accountIcon = newAccountIcon)
val expected = updatedAccount.right()
Truth.assertThat(actual).isEqualTo(expected)
val updatedAccountList = (accountList + updatedAccount).getOrNull()!!
coVerifyOrder {
crudRepository.getAccounts(userWalletId = userWalletId)
crudRepository.saveAccounts(accountList = updatedAccountList)
}
}
@Test
fun `invoke if name and icon are null`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val account = createAccount(accountId = accountId, isMain = true)
val accountList = AccountList(
userWallet = mockk(),
accounts = setOf(account),
totalAccounts = 1,
)
.getOrNull()!!
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId = accountId)
// Assert
val expected = Error.NothingToUpdate.left()
Truth.assertThat(actual).isEqualTo(expected)
coVerify(inverse = true) {
crudRepository.getAccounts(userWalletId = any())
crudRepository.saveAccounts(accountList = any())
}
}
@Test
fun `invoke if getAccounts throws exception`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val newAccountName = AccountName("New name").getOrNull()!!
val exception = IllegalStateException("Test exception")
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } throws exception
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName)
// Assert
val expected = Error.DataOperationFailed(cause = exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) }
}
@Test
fun `invoke if getAccounts returns None`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val accountList = None
val newAccountName = AccountName("New name").getOrNull()!!
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName)
// Assert
val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) }
}
@Test
fun `invoke if getAccounts does not contain accountId`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val account = createAccount(
accountId = AccountId(value = "another-account-id", userWalletId = mockk()),
isMain = true,
)
val accountList = AccountList(
userWallet = mockk(),
accounts = setOf(account),
totalAccounts = 1,
)
.getOrNull()!!
val newAccountName = AccountName("New name").getOrNull()!!
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName)
// Assert
val expected = Error.CriticalTechError.AccountNotFound(accountId = accountId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccounts(userWalletId = userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) }
}
@Test
fun `invoke if saveAccounts throws exception`() = runTest {
// Arrange
val accountId = AccountId(value = "test-account-id", userWalletId = mockk())
val userWalletId = accountId.userWalletId
val account = createAccount(accountId = accountId, isMain = true)
val accountList = AccountList(
userWallet = mockk(),
accounts = setOf(account),
totalAccounts = 1,
).getOrNull()!!
val newAccountName = AccountName("New name").getOrNull()!!
val updatedAccount = account.copy(accountName = newAccountName)
val updatedAccountList = (accountList + updatedAccount).getOrNull()!!
val exception = IllegalStateException("Save failed")
coEvery { crudRepository.getAccounts(userWalletId = userWalletId) } returns accountList.toOption()
coEvery { crudRepository.saveAccounts(accountList = updatedAccountList) } throws exception
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName)
// Assert
val expected = Error.DataOperationFailed(cause = exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
crudRepository.getAccounts(userWalletId = userWalletId)
crudRepository.saveAccounts(accountList = updatedAccountList)
}
}
private fun createAccount(
accountId: AccountId = AccountId(value = randomAccountId(length = 5), userWalletId = mockk()),
accountIcon: CryptoPortfolioIcon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
isMain: Boolean,
): Account.CryptoPortfolio {
return Account.CryptoPortfolio(
accountId = accountId,
name = "Test Account",
accountIcon = accountIcon,
derivationIndex = if (isMain) 0 else Random.nextInt(1, 21),
isArchived = false,
cryptoCurrencyList = Account.CryptoPortfolio.CryptoCurrencyList(
currencies = emptySet(),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
)
.getOrNull()!!
}
}

View file

@ -0,0 +1,8 @@
package com.tangem.domain.account.utils
fun randomAccountId(length: Int): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length)
.map { chars.random() }
.joinToString("")
}