Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-02 11:00:28 +04:00
parent fb9bfa5258
commit 5c576d559f
7 changed files with 83 additions and 56 deletions

View file

@ -5,6 +5,7 @@ import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
@ -28,6 +29,10 @@ class GetUnoccupiedAccountIndexUseCase(
suspend operator fun invoke(userWalletId: UserWalletId): Either<Error, DerivationIndex> = either {
val totalAccountsCount = getTotalAccountsCount(userWalletId = userWalletId)
ensure(totalAccountsCount != 0) {
Error.DataOperationFailed("DerivationIndex cannot be zero because it is reserved for the main account")
}
DerivationIndex(value = totalAccountsCount).getOrElse {
raise(Error.InvalidDerivationIndex(it))
}
@ -60,7 +65,8 @@ class GetUnoccupiedAccountIndexUseCase(
/** Error indicating that a data operation failed */
data class DataOperationFailed(val cause: Throwable) : Error {
override fun toString(): String = "$tag: Data operation failed: ${cause.message ?: "Unknown error"}"
constructor(message: String) : this(cause = IllegalStateException(message))
}
}
}

View file

@ -38,20 +38,20 @@ class UpdateCryptoPortfolioUseCase(
accountName: AccountName? = null,
icon: CryptoPortfolioIcon? = null,
): Either<Error, Account.CryptoPortfolio> = either {
ensure(accountName != null || icon != null) { Error.NothingToUpdate }
validate(accountName, icon)
val accountList = getAccountList(userWalletId = accountId.userWalletId)
val account = accountList.accounts
.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio
?: raise(Error.CriticalTechError.AccountNotFound(accountId = accountId))
?: raise(Error.DataOperationFailed(message = "Account not found: $accountId"))
val updatedAccount = account
.setName(name = accountName)
.setIcon(icon = icon)
val updatedAccounts = (accountList + updatedAccount).getOrElse {
raise(Error.CriticalTechError.AccountListRequirementsNotMet(it))
raise(Error.AccountListRequirementsNotMet(it))
}
saveAccounts(updatedAccounts)
@ -59,12 +59,18 @@ class UpdateCryptoPortfolioUseCase(
updatedAccount
}
private fun Raise<Error>.validate(accountName: AccountName?, icon: CryptoPortfolioIcon?) {
ensure(accountName != null || icon != null) { Error.NothingToUpdate }
}
private suspend fun Raise<Error>.getAccountList(userWalletId: UserWalletId): AccountList {
return catch(
block = { crudRepository.getAccountListSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId)) }
.getOrElse {
raise(Error.DataOperationFailed(message = "Account list not found for wallet $userWalletId"))
}
}
private suspend fun Raise<Error>.saveAccounts(accountList: AccountList) {
@ -92,39 +98,14 @@ class UpdateCryptoPortfolioUseCase(
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"}"
data class AccountListRequirementsNotMet(val cause: AccountList.Error) : Error {
override fun toString(): String = "Account list requirements not met: $cause"
}
/**
* Represents critical technical errors that can occur during the update operation.
* These errors are a consequence of an inconsistent state.
*/
sealed interface CriticalTechError : Error {
/** Error indicating that a data operation failed */
data class DataOperationFailed(val cause: Throwable) : 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"
}
constructor(message: String) : this(cause = IllegalStateException(message))
}
}
}

View file

@ -130,13 +130,12 @@ class AddCryptoPortfolioUseCaseTest {
accountName = newAccount.accountName,
icon = newAccount.icon,
derivationIndex = newAccount.derivationIndex,
)
).leftOrNull() as Error.DataOperationFailed
// 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)
val expected = IllegalStateException("Account list not found for wallet $userWalletId")
Truth.assertThat(actual.cause).isInstanceOf(expected::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message)
coVerifySequence {
singleAccountListFetcher(SingleAccountListFetcher.Params(userWalletId))

View file

@ -4,6 +4,7 @@ import arrow.core.left
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase.Error
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
@ -27,6 +28,23 @@ class GetUnoccupiedAccountIndexUseCaseTest {
clearMocks(crudRepository)
}
@Test
fun `invoke should return error if repository returns 0`() = runTest {
// Arrange
coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } returns 0.toOption()
// Act
val actual = useCase(userWalletId = userWalletId).leftOrNull() as Error.DataOperationFailed
// Assert
val expected =
IllegalStateException("DerivationIndex cannot be zero because it is reserved for the main account")
Truth.assertThat(actual.cause).isInstanceOf(expected::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message)
coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) }
}
@Test
fun `invoke should return next unoccupied index when repository returns count`() = runTest {
// Arrange
@ -36,7 +54,7 @@ class GetUnoccupiedAccountIndexUseCaseTest {
val actual = useCase(userWalletId = userWalletId)
// Assert
val expected = DerivationIndex(4)
val expected = DerivationIndex(3)
Truth.assertThat(actual).isEqualTo(expected)
coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) }
@ -52,7 +70,7 @@ class GetUnoccupiedAccountIndexUseCaseTest {
val actual = useCase(userWalletId = userWalletId)
// Assert
val expected = GetUnoccupiedAccountIndexUseCase.Error.DataOperationFailed(exception).left()
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) }

View file

@ -178,11 +178,12 @@ class UpdateCryptoPortfolioUseCaseTest {
coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName)
val actual = useCase(accountId, newAccountName).leftOrNull() as Error.DataOperationFailed
// Assert
val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId = userWalletId).left()
Truth.assertThat(actual).isEqualTo(expected)
val expected = IllegalStateException("Account list not found for wallet $userWalletId")
Truth.assertThat(actual.cause).isInstanceOf(expected::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) }
@ -202,11 +203,12 @@ class UpdateCryptoPortfolioUseCaseTest {
coEvery { crudRepository.getAccountListSync(userWalletId = userWalletId) } returns accountList.toOption()
// Act
val actual = useCase(accountId = accountId, accountName = newAccountName)
val actual = useCase(accountId, newAccountName).leftOrNull() as Error.DataOperationFailed
// Assert
val expected = Error.CriticalTechError.AccountNotFound(accountId = accountId).left()
Truth.assertThat(actual).isEqualTo(expected)
val expected = IllegalStateException("Account not found: $accountId")
Truth.assertThat(actual.cause).isInstanceOf(expected::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.message)
coVerifyOrder { crudRepository.getAccountListSync(userWalletId = userWalletId) }
coVerify(inverse = true) { crudRepository.saveAccounts(accountList = any()) }

View file

@ -13,7 +13,6 @@ import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.ToastMessage
@ -131,7 +130,6 @@ internal class AccountCreateEditModel @Inject constructor(
// TODO: show alert https://www.figma.com/design/09KKG4ZVuFDZhj8WLv5rGJ/%F0%9F%9A%A7-App-experience?node-id=38882-113775&t=vk6TCy4MkYol1cPb-4
logError(
error = AccountFeatureError.CreateAccount.FailedToCreateAccount(cause = error),
)
}
@ -141,6 +139,7 @@ internal class AccountCreateEditModel @Inject constructor(
val icon = state.account.portfolioIcon.toDomain()
val isNewName = name != params.account.accountName
val isNewIcon = icon != params.account.portfolioIcon
uiState.value = uiState.value.toggleProgress(showProgress = true)
val result = updateCryptoPortfolioUseCase(
icon = if (isNewIcon) icon else null,
@ -148,23 +147,34 @@ internal class AccountCreateEditModel @Inject constructor(
accountId = params.account.accountId,
)
uiState.value = uiState.value.toggleProgress(showProgress = false)
result
.onLeft { showMessage(it.toString()) }
.onLeft(::handleEditAccountError)
.onRight {
showMessage(R.string.account_edit_success_message)
router.pop()
}
}
private fun handleEditAccountError(error: UpdateCryptoPortfolioUseCase.Error) {
if (error is UpdateCryptoPortfolioUseCase.Error.AccountListRequirementsNotMet &&
error.cause is AccountList.Error.DuplicateAccountNames
) {
// TODO("account") show alert that the account name already exists
return
}
// TODO: show alert like in create account flow
logError(
error = AccountFeatureError.EditAccount.FailedToEditAccount(cause = error),
)
}
private fun showMessage(@StringRes id: Int) {
val message = resourceReference(id)
messageSender.send(ToastMessage(message = message))
}
private fun showMessage(text: String) {
messageSender.send(ToastMessage(message = stringReference(text)))
}
private fun onCloseClick() = unsaveChangeDialog()
private fun onIconSelect(icon: CryptoPortfolioIcon.Icon) {

View file

@ -3,6 +3,8 @@ package com.tangem.features.account.createedit.error
import com.tangem.core.error.UniversalError
import com.tangem.domain.account.usecase.AddCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.GetUnoccupiedAccountIndexUseCase
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase
import com.tangem.domain.account.usecase.UpdateCryptoPortfolioUseCase
sealed interface AccountFeatureError : UniversalError {
@ -29,7 +31,16 @@ sealed interface AccountFeatureError : UniversalError {
override val subsystemCode: String get() = "002"
data object RequiredCryptoPortfolio : EditAccount {
data class FailedToEditAccount(val cause: UpdateCryptoPortfolioUseCase.Error) : EditAccount {
override val specificErrorCode: String = "001"
}
}
sealed interface ArchivedAccountList : AccountFeatureError {
override val subsystemCode: String get() = "003"
data class FailedToRecoverAccount(val cause: RecoverCryptoPortfolioUseCase.Error) : ArchivedAccountList {
override val specificErrorCode: String = "001"
}
}