diff --git a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt index 41d100f879..17e09eb709 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AccountDomainModule.kt @@ -39,7 +39,9 @@ internal object AccountDomainModule { @Provides @Singleton - fun provideRecoverCryptoPortfolioUseCase(): RecoverCryptoPortfolioUseCase { - return RecoverCryptoPortfolioUseCase() + fun provideRecoverCryptoPortfolioUseCase( + accountsCRUDRepository: AccountsCRUDRepository, + ): RecoverCryptoPortfolioUseCase { + return RecoverCryptoPortfolioUseCase(crudRepository = accountsCRUDRepository) } } \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt index 76d86bd0fe..723da17196 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsCRUDRepository.kt @@ -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 = 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 } diff --git a/domain/account/src/main/java/com/tangem/domain/account/models/ArchivedAccount.kt b/domain/account/src/main/java/com/tangem/domain/account/models/ArchivedAccount.kt new file mode 100644 index 0000000000..1c017a4f88 --- /dev/null +++ b/domain/account/src/main/java/com/tangem/domain/account/models/ArchivedAccount.kt @@ -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, +) \ No newline at end of file diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt index 70b8543734..a32796f0a1 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsCRUDRepository.kt @@ -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 + /** + * Retrieves a archived account by its unique identifier + * + * @param accountId the unique identifier of the account + */ + suspend fun getArchivedAccount(accountId: AccountId): Option + /** * Saves a list of accounts to the repository * diff --git a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt index 2d27ad3a32..9679a036b6 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCase.kt @@ -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 = 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.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.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.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"}" + } } } \ No newline at end of file diff --git a/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt new file mode 100644 index 0000000000..316e789754 --- /dev/null +++ b/domain/account/src/test/kotlin/com/tangem/domain/account/usecase/RecoverCryptoPortfolioUseCaseTest.kt @@ -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() + + @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") + } +} \ No newline at end of file