Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-11 17:53:32 +04:00
parent d83dda4f3a
commit 947c5e44e6
6 changed files with 374 additions and 11 deletions

View file

@ -39,7 +39,9 @@ internal object AccountDomainModule {
@Provides
@Singleton
fun provideRecoverCryptoPortfolioUseCase(): RecoverCryptoPortfolioUseCase {
return RecoverCryptoPortfolioUseCase()
fun provideRecoverCryptoPortfolioUseCase(
accountsCRUDRepository: AccountsCRUDRepository,
): RecoverCryptoPortfolioUseCase {
return RecoverCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository)
}
}

View file

@ -3,12 +3,13 @@ package com.tangem.data.account.repository
import arrow.core.Option
import arrow.core.Option.Companion.catch
import arrow.core.none
import arrow.core.raise.option
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
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.*
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.addOrReplace
@ -35,6 +36,17 @@ internal class DefaultAccountsCRUDRepository(
?: return none()
}
override suspend fun getArchivedAccount(accountId: AccountId): Option<ArchivedAccount> = option {
ArchivedAccount(
accountId = accountId,
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex(value = 1000).getOrNull()!!,
tokensCount = 2,
networksCount = 1,
)
}
override suspend fun saveAccounts(accountList: AccountList) {
runtimeStore.update(emptyList()) {
it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId }

View file

@ -0,0 +1,29 @@
package com.tangem.domain.account.models
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import kotlinx.serialization.Serializable
/**
* Represents an archived crypto portfolio account
*
* @property accountId the unique identifier of the archived account
* @property name the name of the archived account
* @property icon the icon representing the archived account
* @property derivationIndex the derivation index for the archived account
* @property tokensCount the number of tokens in the archived account
* @property networksCount the number of networks associated with the archived account
*
[REDACTED_AUTHOR]
*/
@Serializable
data class ArchivedAccount(
val accountId: AccountId,
val name: AccountName,
val icon: CryptoPortfolioIcon,
val derivationIndex: DerivationIndex,
val tokensCount: Int,
val networksCount: Int,
)

View file

@ -2,6 +2,7 @@ package com.tangem.domain.account.repository
import arrow.core.Option
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
@ -30,6 +31,13 @@ interface AccountsCRUDRepository {
*/
suspend fun getAccount(accountId: AccountId): Option<Account.CryptoPortfolio>
/**
* Retrieves a archived account by its unique identifier
*
* @param accountId the unique identifier of the account
*/
suspend fun getArchivedAccount(accountId: AccountId): Option<ArchivedAccount>
/**
* Saves a list of accounts to the repository
*

View file

@ -1,25 +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.account.models.AccountList
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
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.wallet.UserWalletId
/**
* Use case for recovering a crypto portfolio account from archived accounts
*
* @property crudRepository repository for performing CRUD operations on accounts
*
[REDACTED_AUTHOR]
*/
class RecoverCryptoPortfolioUseCase {
class RecoverCryptoPortfolioUseCase(
private val crudRepository: AccountsCRUDRepository,
) {
/**
* Recovers a crypto portfolio account by moving it from archived accounts to active accounts
*
* @param accountId the unique identifier of the account to recover
*/
suspend operator fun invoke(accountId: AccountId): Either<Error, Account.CryptoPortfolio> = either {
raise(Error.DataOperationFailed)
val accountList = getAccountList(userWalletId = accountId.userWalletId)
val archivedAccount = getArchivedAccount(accountId = accountId)
// TODO: [REDACTED_JIRA]
// Remove the account from the list of archived accounts
// Add the account to the list of active accounts
// Save to backend
val recoveredAccount = archivedAccount.recover()
val updatedAccountList = (accountList + recoveredAccount)
.getOrElse { raise(Error.CriticalTechError.AccountListRequirementsNotMet(cause = it)) }
saveAccounts(updatedAccountList)
recoveredAccount
}
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>.getArchivedAccount(accountId: AccountId): ArchivedAccount {
return catch(
block = { crudRepository.getArchivedAccount(accountId = accountId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse {
raise(Error.CriticalTechError.AccountNotFound(accountId = accountId))
}
}
private fun ArchivedAccount.recover(): Account.CryptoPortfolio {
return Account.CryptoPortfolio(
accountId = this.accountId,
accountName = this.name,
accountIcon = this.icon,
derivationIndex = this.derivationIndex,
isArchived = false,
cryptoCurrencyList = Account.CryptoPortfolio.CryptoCurrencyList(
currencies = emptySet(),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
)
}
private suspend fun Raise<Error>.saveAccounts(accountList: AccountList) {
catch(
block = { crudRepository.saveAccounts(accountList) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
}
/**
* Represents possible errors that can occur during the add operation
*/
sealed interface Error {
data object DataOperationFailed : Error
val tag: String
get() = this::class.simpleName ?: "RecoverCryptoPortfolioUseCase.Error"
/**
* Critical technical errors that can occur during the recovery operation
*/
sealed interface CriticalTechError : Error {
/**
*
* @property userWalletId the unique identifier of the user wallet
*/
data class AccountsNotCreated(val userWalletId: UserWalletId) : CriticalTechError {
override fun toString(): String = "$tag: 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 = "$tag: 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) : Error {
override fun toString(): String = "$tag: Account list requirements not met: $cause"
}
}
/** 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"}"
}
}
}

View file

@ -0,0 +1,208 @@
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.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.usecase.RecoverCryptoPortfolioUseCase.Error
import com.tangem.domain.account.utils.createAccount
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
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
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RecoverCryptoPortfolioUseCaseTest {
private val crudRepository: AccountsCRUDRepository = mockk(relaxUnitFun = true)
private val useCase = RecoverCryptoPortfolioUseCase(crudRepository)
private val userWallet = mockk<UserWallet>()
@BeforeEach
fun resetMocks() {
clearMocks(crudRepository, userWallet)
every { userWallet.walletId } returns userWalletId
}
@Test
fun `invoke should recover archived crypto portfolio account`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val archivedAccount = ArchivedAccount(
accountId = account.accountId,
name = account.name,
icon = account.icon,
derivationIndex = account.derivationIndex,
tokensCount = 1,
networksCount = 1,
)
val recoveredAccount = account.copy(isArchived = false)
val updatedAccountList = (accountList + recoveredAccount).getOrNull()!!
coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption()
// Act
val actual = useCase(account.accountId)
// Assert
val expected = recoveredAccount.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
crudRepository.getAccounts(userWalletId)
crudRepository.getArchivedAccount(account.accountId)
crudRepository.saveAccounts(updatedAccountList)
}
}
@Test
fun `invoke should return error if getAccounts returns None`() = runTest {
// Arrange
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex.Main,
)
coEvery { crudRepository.getAccounts(userWalletId) } returns None
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.CriticalTechError.AccountsNotCreated(userWalletId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccounts(userWalletId) }
coVerify(inverse = true) {
crudRepository.getArchivedAccount(any())
crudRepository.saveAccounts(any())
}
}
@Test
fun `invoke should return error if getAccounts throws exception`() = runTest {
// Arrange
val accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex.Main,
)
val exception = IllegalStateException("Test error")
coEvery { crudRepository.getAccounts(userWalletId) } throws exception
// Act
val actual = useCase(accountId)
// Assert
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { crudRepository.getAccounts(userWalletId) }
coVerify(inverse = true) {
crudRepository.getArchivedAccount(any())
crudRepository.saveAccounts(any())
}
}
@Test
fun `invoke should return error if getArchivedAccount throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val exception = IllegalStateException("Test error")
coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.getArchivedAccount(account.accountId) } throws exception
// Act
val actual = useCase(account.accountId)
// Assert
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
crudRepository.getAccounts(userWalletId)
crudRepository.getArchivedAccount(account.accountId)
}
coVerify(inverse = true) { crudRepository.saveAccounts(any()) }
}
@Test
fun `invoke should return error if getArchivedAccount returns null`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.getArchivedAccount(account.accountId) } returns None
// Act
val actual = useCase(account.accountId)
// Assert
val expected = Error.CriticalTechError.AccountNotFound(account.accountId).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
crudRepository.getAccounts(userWalletId)
crudRepository.getArchivedAccount(account.accountId)
}
coVerify(inverse = true) { crudRepository.saveAccounts(any()) }
}
@Test
fun `invoke should return error if saveAccounts throws exception`() = runTest {
// Arrange
val account = createAccount(userWalletId)
val accountList = AccountList.empty(userWallet)
val archivedAccount = ArchivedAccount(
accountId = account.accountId,
name = account.name,
icon = account.icon,
derivationIndex = account.derivationIndex,
tokensCount = 1,
networksCount = 1,
)
val recoveredAccount = account.copy(isArchived = false)
val updatedAccountList = (accountList + recoveredAccount).getOrNull()!!
val exception = IllegalStateException("Save failed")
coEvery { crudRepository.getAccounts(userWalletId) } returns accountList.toOption()
coEvery { crudRepository.getArchivedAccount(account.accountId) } returns archivedAccount.toOption()
coEvery { crudRepository.saveAccounts(updatedAccountList) } throws exception
// Act
val actual = useCase(account.accountId)
// Assert
val expected = Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
crudRepository.getAccounts(userWalletId)
crudRepository.getArchivedAccount(account.accountId)
crudRepository.saveAccounts(updatedAccountList)
}
}
private companion object {
val userWalletId = UserWalletId("011")
}
}