Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-01 13:58:38 +03:00
commit 9ba66ba084
179 changed files with 5757 additions and 1156 deletions

View file

@ -9,9 +9,14 @@ android {
namespace = "com.tangem.data.account"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// endregion
@ -20,18 +25,33 @@ dependencies {
api(projects.domain.models)
// endregion
// Project - Data
implementation(projects.core.datasource)
// region Project - Data
implementation(projects.data.common)
// endregion
// region DI
implementation(deps.hilt.core)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region AndroidX libraries
implementation(deps.androidx.datastore)
// endregion
// region Other Dependencies
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
// endregion
// region Test
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.account.converter
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
/**
* Container for converter factories related to accounts.
*
* @property accountsListCF factory for creating an account list converter
* @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter
* @property cryptoPortfolioCF factory for creating a crypto portfolio converter
*
* @constructor Creates an instance of the container with injected factories.
*
[REDACTED_AUTHOR]
*/
internal class AccountConverterFactoryContainer @Inject constructor(
val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
private val accountsListCF: AccountListConverter.Factory,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userWalletsStore: UserWalletsStore,
) {
fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return accountsListCF.create(userWallet)
}
fun createCryptoPortfolioConverter(userWalletId: UserWalletId): CryptoPortfolioConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return cryptoPortfolioCF.create(userWallet)
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.account.converter
import arrow.core.getOrElse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
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 com.tangem.domain.models.wallet.UserWalletId
internal fun String.toAccountId(userWalletId: UserWalletId): AccountId {
return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse {
error("Unable to create AccountId from value: $this. Cause: $it")
}
}
internal fun String.toAccountName(): AccountName {
return AccountName(value = this).getOrElse {
error("Unable to create AccountName from value: $this. Cause: $it")
}
}
internal fun WalletAccountDTO.toIcon(): CryptoPortfolioIcon {
return CryptoPortfolioIconConverter.convert(
value = CryptoPortfolioIconConverter.DataModel(icon = icon, color = iconColor),
)
}
internal fun Int.toDerivationIndex(): DerivationIndex {
return DerivationIndex(value = this).getOrElse {
error("Unable to create DerivationIndex from value: $this. Cause: $it")
}
}

View file

@ -0,0 +1,46 @@
package com.tangem.data.account.converter
import arrow.core.getOrElse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.Converter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Converts a [GetWalletAccountsResponse] to an [AccountList] and vice versa
*
* @property userWallet the user wallet associated with the account list
* @param cryptoPortfolioConverterFactory factory to create [CryptoPortfolioConverter] instances
*
[REDACTED_AUTHOR]
*/
internal class AccountListConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory,
) : Converter<GetWalletAccountsResponse, AccountList> {
private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy {
cryptoPortfolioConverterFactory.create(userWallet)
}
override fun convert(value: GetWalletAccountsResponse): AccountList {
return AccountList(
userWallet = userWallet,
accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(),
totalAccounts = value.wallet.totalAccounts,
sortType = TokensSortTypeConverter.convert(value.wallet.sort),
groupType = TokensGroupTypeConverter.convert(value.wallet.group),
)
.getOrElse {
error("Failed to convert GetWalletAccountsResponse to AccountList: $it")
}
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): AccountListConverter
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.converter.Converter
/**
* Converts a [WalletAccountDTO] to an [ArchivedAccount]
*
* @param userWalletId the ID of the user wallet associated with the account
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountConverter(
private val userWalletId: UserWalletId,
) : Converter<WalletAccountDTO, ArchivedAccount> {
override fun convert(value: WalletAccountDTO): ArchivedAccount {
return ArchivedAccount(
accountId = value.id.toAccountId(userWalletId = userWalletId),
name = value.name.toAccountName(),
icon = value.toIcon(),
derivationIndex = value.derivationIndex.toDerivationIndex(),
tokensCount = value.totalTokens ?: error("Total tokens should not be null"),
networksCount = value.totalNetworks ?: error("Total networks should not be null"),
)
}
}

View file

@ -0,0 +1,61 @@
package com.tangem.data.account.converter
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.TwoWayConverter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
* Converts a [WalletAccountDTO] to an [Account.CryptoPortfolio] and vise versa
*
* @property userWallet the user wallet associated with the account list
* @property responseCryptoCurrenciesFactory factory to create crypto currencies from response tokens
*
[REDACTED_AUTHOR]
*/
internal class CryptoPortfolioConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val userTokensResponseFactory: UserTokensResponseFactory,
) : TwoWayConverter<WalletAccountDTO, Account.CryptoPortfolio> {
override fun convert(value: WalletAccountDTO): Account.CryptoPortfolio {
val tokens = value.tokens ?: error("Tokens should not be null")
return Account.CryptoPortfolio(
accountId = value.id.toAccountId(userWallet.walletId),
accountName = value.name.toAccountName(),
icon = value.toIcon(),
derivationIndex = value.derivationIndex.toDerivationIndex(),
cryptoCurrencies = if (tokens.isNotEmpty()) {
responseCryptoCurrenciesFactory.createCurrencies(
tokens = tokens,
userWallet = userWallet,
).toSet()
} else {
emptySet()
},
)
}
override fun convertBack(value: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
id = value.accountId.value,
name = value.accountName.value,
derivationIndex = value.derivationIndex.value,
icon = value.icon.value.name,
iconColor = value.icon.color.name,
tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken),
)
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet): CryptoPortfolioConverter
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.data.account.converter
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.utils.converter.Converter
/**
* Converts a [CryptoPortfolioIconConverter.DataModel] to a [CryptoPortfolioIcon]
*
[REDACTED_AUTHOR]
*/
internal object CryptoPortfolioIconConverter : Converter<CryptoPortfolioIconConverter.DataModel, CryptoPortfolioIcon> {
override fun convert(value: DataModel): CryptoPortfolioIcon {
return CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.valueOf(value.icon),
color = CryptoPortfolioIcon.Color.valueOf(value.color),
)
}
data class DataModel(val icon: String, val color: String)
}

View file

@ -0,0 +1,44 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.Converter
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
/**
[REDACTED_AUTHOR]
*/
internal class GetWalletAccountsResponseConverter @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
@Assisted val version: Int,
cryptoPortfolioConverterFactory: CryptoPortfolioConverter.Factory,
) : Converter<AccountList, GetWalletAccountsResponse> {
private val cryptoPortfolioConverter: CryptoPortfolioConverter by lazy {
cryptoPortfolioConverterFactory.create(userWallet)
}
override fun convert(value: AccountList): GetWalletAccountsResponse {
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = version,
group = TokensGroupTypeConverter.convertBack(value.groupType),
sort = TokensSortTypeConverter.convertBack(value.sortType),
totalAccounts = value.totalAccounts,
),
accounts = value.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map(cryptoPortfolioConverter::convertBack),
unassignedTokens = emptyList(),
)
}
@AssistedFactory
interface Factory {
fun create(userWallet: UserWallet, version: Int): GetWalletAccountsResponseConverter
}
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.utils.converter.Converter
/**
* Converts an [AccountList] to a [SaveWalletAccountsResponse]
*
[REDACTED_AUTHOR]
*/
internal object SaveWalletAccountsResponseConverter : Converter<AccountList, SaveWalletAccountsResponse> {
override fun convert(value: AccountList): SaveWalletAccountsResponse {
return SaveWalletAccountsResponse(
accounts = value.accounts
.filterIsInstance<Account.CryptoPortfolio>()
.map(::toDTO),
)
}
private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
id = account.accountId.value,
name = account.accountName.value,
derivationIndex = account.derivationIndex.value,
icon = account.icon.value.name,
iconColor = account.icon.color.name,
)
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensGroupType
import com.tangem.utils.converter.TwoWayConverter
/**
* Converts a [UserTokensResponse.GroupType] to a [TokensGroupType] and vice versa
*
[REDACTED_AUTHOR]
*/
internal object TokensGroupTypeConverter : TwoWayConverter<UserTokensResponse.GroupType, TokensGroupType> {
override fun convert(value: UserTokensResponse.GroupType): TokensGroupType {
return when (value) {
UserTokensResponse.GroupType.NETWORK -> TokensGroupType.NETWORK
UserTokensResponse.GroupType.NONE,
UserTokensResponse.GroupType.TOKEN,
-> TokensGroupType.NONE
}
}
override fun convertBack(value: TokensGroupType): UserTokensResponse.GroupType {
return when (value) {
TokensGroupType.NONE -> UserTokensResponse.GroupType.NONE
TokensGroupType.NETWORK -> UserTokensResponse.GroupType.NETWORK
}
}
}

View file

@ -0,0 +1,29 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensSortType
import com.tangem.utils.converter.TwoWayConverter
/**
* Converts a [UserTokensResponse.SortType] to a [TokensSortType] and vice versa
*
[REDACTED_AUTHOR]
*/
internal object TokensSortTypeConverter : TwoWayConverter<UserTokensResponse.SortType, TokensSortType> {
override fun convert(value: UserTokensResponse.SortType): TokensSortType {
return when (value) {
UserTokensResponse.SortType.BALANCE -> TokensSortType.BALANCE
UserTokensResponse.SortType.MANUAL,
UserTokensResponse.SortType.MARKETCAP,
-> TokensSortType.NONE
}
}
override fun convertBack(value: TokensSortType): UserTokensResponse.SortType {
return when (value) {
TokensSortType.NONE -> UserTokensResponse.SortType.MANUAL
TokensSortType.BALANCE -> UserTokensResponse.SortType.BALANCE
}
}
}

View file

@ -1,9 +1,13 @@
package com.tangem.data.account.di
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -16,10 +20,20 @@ internal object AccountDataModule {
@Provides
@Singleton
fun provideAccountsCRUDRepository(userWalletsStore: UserWalletsStore): AccountsCRUDRepository {
fun provideAccountsCRUDRepository(
tangemTechApi: TangemTechApi,
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userWalletsStore: UserWalletsStore,
accountConverterFactoryContainer: AccountConverterFactoryContainer,
dispatchers: CoroutineDispatcherProvider,
): AccountsCRUDRepository {
return DefaultAccountsCRUDRepository(
runtimeStore = RuntimeSharedStore(),
tangemTechApi = tangemTechApi,
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = ArchivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
convertersContainer = accountConverterFactoryContainer,
dispatchers = dispatchers,
)
}
}

View file

@ -1,90 +1,150 @@
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 arrow.core.toOption
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.converter.ArchivedAccountConverter
import com.tangem.data.account.converter.SaveWalletAccountsResponseConverter
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
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.*
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.addOrReplace
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
/**
[REDACTED_AUTHOR]
*/
// TODO: [REDACTED_JIRA]
internal class DefaultAccountsCRUDRepository(
private val runtimeStore: RuntimeSharedStore<List<AccountList>>,
private val tangemTechApi: TangemTechApi,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory,
private val userWalletsStore: UserWalletsStore,
private val convertersContainer: AccountConverterFactoryContainer,
private val dispatchers: CoroutineDispatcherProvider,
) : AccountsCRUDRepository {
override suspend fun getAccounts(userWalletId: UserWalletId): Option<AccountList> = catch {
runtimeStore.getSyncOrNull()
?.firstOrNull { it.userWallet.walletId == userWalletId }
?: return none()
private val saveAccountsMutex = Mutex()
override suspend fun getAccountListSync(userWalletId: UserWalletId): Option<AccountList> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
ensureNotNull(accountListResponse)
val converter = convertersContainer.createAccountListConverter(userWalletId = userWalletId)
converter.convert(value = accountListResponse)
}
override suspend fun getAccount(accountId: AccountId): Option<Account.CryptoPortfolio> = catch {
runtimeStore.getSyncOrNull().orEmpty()
.flatMap { it.accounts }
.firstOrNull { it.accountId == accountId } as? Account.CryptoPortfolio
?: return none()
override suspend fun getAccountSync(accountId: AccountId): Option<Account.CryptoPortfolio> = option {
val userWalletId = accountId.userWalletId
val accountResponse = getAccountsResponseSync(userWalletId = userWalletId)
?.accounts?.firstOrNull { it.id == accountId.value }
ensureNotNull(accountResponse)
val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = userWalletId)
converter.convert(value = accountResponse)
}
override suspend fun getArchivedAccount(accountId: AccountId): Option<ArchivedAccount> = option {
createMockArchivedAccount(userWalletId = accountId.userWalletId)
override suspend fun getArchivedAccountSync(accountId: AccountId): Option<ArchivedAccount> {
val store = getArchivedAccountsStore(userWalletId = accountId.userWalletId)
return store.getSyncOrNull()
?.firstOrNull { it.accountId == accountId }
.toOption()
}
override suspend fun getArchivedAccountsSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>> = option {
listOf(
createMockArchivedAccount(userWalletId),
)
override suspend fun getArchivedAccountListSync(userWalletId: UserWalletId): Option<List<ArchivedAccount>> {
val store = getArchivedAccountsStore(userWalletId = userWalletId)
return store.getSyncOrNull().toOption()
}
override fun getArchivedAccounts(userWalletId: UserWalletId): Flow<List<ArchivedAccount>> {
return flow {
getArchivedAccountsSync(userWalletId).getOrNull().orEmpty()
}
val store = getArchivedAccountsStore(userWalletId = userWalletId)
return store.get()
}
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) = Unit
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) {
val response = withContext(dispatchers.io) {
tangemTechApi.getWalletArchivedAccounts(walletId = userWalletId.stringValue).getOrThrow()
}
val store = getArchivedAccountsStore(userWalletId = userWalletId)
val converter = ArchivedAccountConverter(userWalletId = userWalletId)
val archivedAccounts = converter.convertList(input = response.accounts)
store.store(value = archivedAccounts)
}
override suspend fun saveAccounts(accountList: AccountList) {
runtimeStore.update(emptyList()) {
it.addOrReplace(accountList) { it.userWallet.walletId == accountList.userWallet.walletId }
saveAccountsMutex.withLock {
val store = getAccountsResponseStore(userWalletId = accountList.userWallet.walletId)
val version = store.data.firstOrNull()?.wallet?.version ?: 0
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
withContext(dispatchers.io) {
tangemTechApi.saveWalletAccounts(
walletId = accountList.userWallet.walletId.stringValue,
ifMatch = version.toString(),
body = body,
)
.getOrThrow()
}
val converter = convertersContainer.getWalletAccountsResponseCF.create(
userWallet = accountList.userWallet,
version = version,
)
val accountsResponse = converter.convert(value = accountList)
store.updateData { accountsResponse }
}
}
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Int {
val activeAccountsCount = runtimeStore.getSyncOrNull()?.size ?: 1
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option<Int> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
return activeAccountsCount + 1
ensureNotNull(accountListResponse)
return accountListResponse.wallet.totalAccounts.toOption()
}
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsStore.getSyncStrict(userWalletId)
}
private fun createMockArchivedAccount(userWalletId: UserWalletId): ArchivedAccount {
val derivationIndex = DerivationIndex(value = 1000).getOrNull()!!
private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? {
val store = getAccountsResponseStore(userWalletId = userWalletId)
return store.data.firstOrNull()
}
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = derivationIndex,
),
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = derivationIndex,
tokensCount = 2,
networksCount = 1,
)
private fun getAccountsResponseStore(userWalletId: UserWalletId): AccountsResponseStore {
return accountsResponseStoreFactory.create(userWalletId = userWalletId)
}
private fun getArchivedAccountsStore(userWalletId: UserWalletId): ArchivedAccountsStore {
return archivedAccountsStoreFactory.create(userWalletId)
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.account.store
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
/**
* Factory class for creating and managing instances of [AccountsResponseStore].
* This class is responsible for creating a [DataStore] for each unique [UserWalletId].
*
* @property context application context used to access the file system
* @property moshi moshi instance for JSON serialization and deserialization
* @property dispatchers coroutine dispatcher provider
*
[REDACTED_AUTHOR]
*/
internal class AccountsResponseStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
@NetworkMoshi private val moshi: Moshi,
private val dispatchers: CoroutineDispatcherProvider,
) {
@OptIn(ExperimentalStdlibApi::class)
private val adapter by lazy { moshi.adapter<GetWalletAccountsResponse?>() }
private val createdDataStores = ConcurrentHashMap<UserWalletId, AccountsResponseStore>()
/**
* Creates or retrieves an [AccountsResponseStore] for the given [UserWalletId].
*
* @param userWalletId the unique identifier of the user's wallet
*/
fun create(userWalletId: UserWalletId): AccountsResponseStore {
return createdDataStores.computeIfAbsent(userWalletId) {
DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(defaultValue = null, adapter = adapter),
produceFile = { context.dataStoreFile(fileName = "wallet_accounts_${userWalletId.stringValue}") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
)
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, AccountsResponseStore> = createdDataStores.toMap()
@VisibleForTesting
fun clearStores() {
createdDataStores.clear()
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlin.time.Duration.Companion.seconds
/**
* Store for managing archived accounts with support for data expiration
*
* @property runtimeStore the underlying runtime shared store for storing the list of archived accounts
*
[REDACTED_AUTHOR]
*/
internal class ArchivedAccountsStore(
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?>,
) {
private var timestamp: Long? = null
/** Retrieves a flow of archived accounts, filtering out null values */
fun get(): Flow<List<ArchivedAccount>> {
return runtimeStore.get()
.map {
if (isDataExpired()) null else it
}
.filterNotNull()
}
/** Retrieves the list of archived accounts synchronously, or null if the data is expired */
suspend fun getSyncOrNull(): List<ArchivedAccount>? {
if (isDataExpired()) return null
return runtimeStore.getSyncOrNull()
}
/** Stores the provided list of archived accounts [value] */
suspend fun store(value: List<ArchivedAccount>) {
timestamp = System.currentTimeMillis()
runtimeStore.store(value)
}
private fun isDataExpired(): Boolean {
val currentTime = System.currentTimeMillis()
val storedTime = timestamp ?: return true
return currentTime - storedTime >= EXPIRATION_DURATION_MS
}
@VisibleForTesting
fun setTimestamp(time: Long) {
timestamp = time
}
@VisibleForTesting
fun clear() {
timestamp = null
runtimeStore.clear()
}
private companion object Companion {
val EXPIRATION_DURATION_MS = 120.seconds.inWholeMicroseconds
}
}

View file

@ -0,0 +1,37 @@
package com.tangem.data.account.store
import androidx.annotation.VisibleForTesting
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.models.wallet.UserWalletId
import java.util.concurrent.ConcurrentHashMap
/**
* Factory for creating and managing instances of [ArchivedAccountsStore].
* and reused for each unique [UserWalletId].
*
[REDACTED_AUTHOR]
*/
internal object ArchivedAccountsStoreFactory {
private val createdRuntimeStores = ConcurrentHashMap<UserWalletId, ArchivedAccountsStore>()
/**
* Creates or retrieves an existing instance of [ArchivedAccountsStore] for the given [userWalletId].
*
* @param userWalletId the unique identifier for the user wallet
*/
fun create(userWalletId: UserWalletId): ArchivedAccountsStore {
return createdRuntimeStores.computeIfAbsent(userWalletId) {
ArchivedAccountsStore(runtimeStore = RuntimeStateStore(defaultValue = null))
}
}
@VisibleForTesting
fun getAllStores(): Map<UserWalletId, ArchivedAccountsStore> = createdRuntimeStores.toMap()
@VisibleForTesting
fun clearStores() {
createdRuntimeStores.clear()
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
internal fun createWalletAccountDTO(
userWalletId: UserWalletId,
accountId: String? = null,
accountName: String? = null,
icon: String? = null,
iconColor: String? = null,
derivationIndex: Int? = null,
tokens: List<UserTokensResponse.Token>? = emptyList(),
): WalletAccountDTO {
val mainAccount = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
return WalletAccountDTO(
id = accountId ?: mainAccount.accountId.value,
name = accountName ?: mainAccount.accountName.value,
derivationIndex = derivationIndex ?: mainAccount.derivationIndex.value,
icon = icon ?: mainAccount.icon.value.name,
iconColor = iconColor ?: mainAccount.icon.color.name,
tokens = tokens,
)
}
internal fun createCryptoPortfolio(userWalletId: UserWalletId): Account.CryptoPortfolio {
return Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)
}
internal fun createGetWalletAccountsResponse(
userWalletId: UserWalletId,
groupType: UserTokensResponse.GroupType = UserTokensResponse.GroupType.NETWORK,
sortType: UserTokensResponse.SortType = UserTokensResponse.SortType.BALANCE,
accountId: String? = null,
accountName: String? = null,
icon: String? = null,
iconColor: String? = null,
derivationIndex: Int? = null,
tokens: List<UserTokensResponse.Token>? = emptyList(),
): GetWalletAccountsResponse {
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 0,
group = groupType,
sort = sortType,
totalAccounts = 1,
),
accounts = buildList {
createWalletAccountDTO(
userWalletId = userWalletId,
accountId = accountId,
accountName = accountName,
icon = icon,
iconColor = iconColor,
derivationIndex = derivationIndex,
tokens = tokens,
)
.let(::add)
},
unassignedTokens = emptyList(),
)
}
internal fun createAccountList(
userWallet: UserWallet,
sortType: TokensSortType = TokensSortType.BALANCE,
groupType: TokensGroupType = TokensGroupType.NETWORK,
): AccountList {
return AccountList(
userWallet = userWallet,
accounts = setOf(createCryptoPortfolio(userWallet.walletId)),
totalAccounts = 1,
sortType = sortType,
groupType = groupType,
)
.getOrNull()!!
}

View file

@ -0,0 +1,159 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AccountListConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val cryptoPortfolioConverterFactory = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val converter = AccountListConverter(userWallet, cryptoPortfolioConverterFactory)
@BeforeAll
fun setupAll() {
every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
clearMocks(cryptoPortfolioConverter)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val dto = createGetWalletAccountsResponse(userWallet.walletId)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convert(any()) } throws exception
// Act
val actual = runCatching { converter.convert(dto) }.exceptionOrNull()!!
// Asset
val expected = exception
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual.message).isEqualTo(expected.message)
}
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Arrange
if (model.expected.isSuccess) {
model.value.accounts.forEach { dto ->
val account = model.expected.getOrNull()!!.accounts
.firstOrNull { it.accountId.value == dto.id } as? Account.CryptoPortfolio
every { cryptoPortfolioConverter.convert(dto) } returns account!!
}
}
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.BALANCE,
groupType = UserTokensResponse.GroupType.NETWORK,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
),
),
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MANUAL,
groupType = UserTokensResponse.GroupType.TOKEN,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
),
),
ConvertModel(
value = createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MARKETCAP,
groupType = UserTokensResponse.GroupType.NONE,
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
),
),
ConvertModel(
value = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 0,
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 1,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
),
expected = Result.failure(
IllegalStateException(
"Failed to convert GetWalletAccountsResponse to AccountList: EmptyAccountsList: " +
"The accounts list cannot be empty",
),
),
),
)
}
}
data class ConvertModel(
val value: GetWalletAccountsResponse,
val expected: Result<AccountList>,
)
}

View file

@ -0,0 +1,144 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.ArchivedAccount
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 com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountConverterTest {
private val userWalletId = UserWalletId("011")
private val converter = ArchivedAccountConverter(userWalletId = userWalletId)
@ParameterizedTest
@ProvideTestModels
fun convert(model: TestModel) {
// Act
val actual = runCatching { converter.convert(value = model.value) }
// Assert
actual
.onSuccess {
val expected = model.expected.getOrNull()!!
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<TestModel> {
return listOf(
TestModel(
value = createDTO(),
expected = Result.success(createDomain()),
),
TestModel(
value = createDTO(accountId = "123"),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}",
),
),
),
TestModel(
value = createDTO(name = ""),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}",
),
),
),
TestModel(
value = createDTO(icon = "INVALID_ICON"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
TestModel(
value = createDTO(iconColor = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
TestModel(
value = createDTO(derivationIndex = -1),
expected = Result.failure(
IllegalStateException(
"Unable to create DerivationIndex from value: -1. " +
"Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1",
),
),
),
TestModel(
value = createDTO(totalTokens = null),
expected = Result.failure(
IllegalStateException("Total tokens should not be null"),
),
),
TestModel(
value = createDTO(totalNetworks = null),
expected = Result.failure(
IllegalStateException("Total networks should not be null"),
),
),
)
}
private fun createDTO(
accountId: String = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027",
name: String = "Test Account",
icon: String = "Letter",
iconColor: String = "Azure",
derivationIndex: Int = 0,
totalTokens: Int? = 1,
totalNetworks: Int? = 1,
): WalletAccountDTO {
return WalletAccountDTO(
id = accountId,
name = name,
derivationIndex = derivationIndex,
icon = icon,
iconColor = iconColor,
tokens = null,
totalTokens = totalTokens,
totalNetworks = totalNetworks,
)
}
private fun createDomain(): ArchivedAccount {
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(0).getOrNull()!!),
name = "Test Account".toAccountName(),
derivationIndex = 0.toDerivationIndex(),
icon = CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
tokensCount = 1,
networksCount = 1,
)
}
data class TestModel(
val value: WalletAccountDTO,
val expected: Result<ArchivedAccount>,
)
}

View file

@ -0,0 +1,155 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
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.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CryptoPortfolioConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory = mockk()
private val userTokensResponseFactory: UserTokensResponseFactory = mockk()
private val converter = CryptoPortfolioConverter(
userWallet = userWallet,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
userTokensResponseFactory = userTokensResponseFactory,
)
@BeforeEach
fun setupEach() {
clearMocks(responseCryptoCurrenciesFactory, userTokensResponseFactory)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId),
expected = Result.success(createCryptoPortfolio(userWalletId = userWallet.walletId)),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountId = "123"),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountId from value: 123. Cause: ${AccountId.Error.InvalidFormat}",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, accountName = ""),
expected = Result.failure(
IllegalStateException(
"Unable to create AccountName from value: . Cause: ${AccountName.Error.Empty}",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, icon = "INVALID_ICON"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, iconColor = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, derivationIndex = -1),
expected = Result.failure(
IllegalStateException(
"Unable to create DerivationIndex from value: -1. " +
"Cause: NegativeDerivationIndex: Derivation index cannot be negative: -1",
),
),
),
ConvertModel(
value = createWalletAccountDTO(userWalletId = userWallet.walletId, tokens = null),
expected = Result.failure(
IllegalStateException("Tokens should not be null"),
),
),
)
}
}
data class ConvertModel(
val value: WalletAccountDTO,
val expected: Result<Account.CryptoPortfolio>,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = converter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertBackModel> {
return listOf(
ConvertBackModel(
value = createCryptoPortfolio(userWalletId = userWallet.walletId),
expected = createWalletAccountDTO(userWalletId = userWallet.walletId),
),
)
}
}
data class ConvertBackModel(
val value: Account.CryptoPortfolio,
val expected: WalletAccountDTO,
)
}

View file

@ -0,0 +1,66 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.account.converter.CryptoPortfolioIconConverter.DataModel
import com.tangem.domain.models.account.CryptoPortfolioIcon
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class CryptoPortfolioIconConverterTest {
@ParameterizedTest
@ProvideTestModels
fun convert(model: TestModel) {
// Act
val actual = runCatching { CryptoPortfolioIconConverter.convert(model.value) }
// Assert
actual
.onSuccess {
val expected = model.expected.getOrNull()!!
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<TestModel> {
return listOf(
TestModel(
value = DataModel(icon = "Letter", color = "Azure"),
expected = Result.success(
CryptoPortfolioIcon.ofCustomAccount(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
),
),
TestModel(
value = DataModel(icon = "INVALID_ICON", color = "Azure"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Icon.INVALID_ICON",
),
),
),
TestModel(
value = DataModel(icon = "Letter", color = "INVALID_COLOR"),
expected = Result.failure(
IllegalArgumentException(
"No enum constant com.tangem.domain.models.account.CryptoPortfolioIcon.Color.INVALID_COLOR",
),
),
),
)
}
data class TestModel(
val value: DataModel,
val expected: Result<CryptoPortfolioIcon>,
)
}

View file

@ -0,0 +1,130 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetWalletAccountsResponseConverterTest {
private val userWallet = mockk<UserWallet> {
every { walletId } returns UserWalletId("011")
}
private val cryptoPortfolioConverterFactory = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val converter = GetWalletAccountsResponseConverter(
userWallet = userWallet,
version = 0,
cryptoPortfolioConverterFactory = cryptoPortfolioConverterFactory,
)
@BeforeAll
fun setupAll() {
every { cryptoPortfolioConverterFactory.create(userWallet) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
clearMocks(cryptoPortfolioConverter)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val domain = createAccountList(userWallet = userWallet)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convertBack(any()) } throws exception
// Act
val actual = runCatching { converter.convert(domain) }.exceptionOrNull()!!
// Asset
val expected = exception
Truth.assertThat(actual).isInstanceOf(expected::class.java)
Truth.assertThat(actual.message).isEqualTo(expected.message)
}
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Arrange
if (model.expected.isSuccess) {
model.value.accounts.forEach { domain ->
val dto = model.expected.getOrNull()!!.accounts.firstOrNull { it.id == domain.accountId.value }
every { cryptoPortfolioConverter.convertBack(domain as Account.CryptoPortfolio) } returns dto!!
}
}
// Act
val actual = runCatching { converter.convert(model.value) }
// Asset
actual
.onSuccess {
val expected = model.expected.getOrNull()
Truth.assertThat(it).isEqualTo(expected)
}
.onFailure {
val expected = model.expected.exceptionOrNull() ?: throw it
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it.message).isEqualTo(expected.message)
}
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = createAccountList(
userWallet = userWallet,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
expected = Result.success(
createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.BALANCE,
groupType = UserTokensResponse.GroupType.NETWORK,
),
),
),
ConvertModel(
value = createAccountList(
userWallet = userWallet,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
expected = Result.success(
createGetWalletAccountsResponse(
userWalletId = userWallet.walletId,
sortType = UserTokensResponse.SortType.MANUAL,
groupType = UserTokensResponse.GroupType.NONE,
),
),
),
)
}
}
data class ConvertModel(
val value: AccountList,
val expected: Result<GetWalletAccountsResponse>,
)
}

View file

@ -0,0 +1,50 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SaveWalletAccountsResponseConverterTest {
@Test
fun convert() {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns UserWalletId("011")
}
val accountList = AccountList(
userWallet = userWallet,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)),
totalAccounts = 1,
)
.getOrNull()!!
// Act
val actual = SaveWalletAccountsResponseConverter.convert(value = accountList)
// Assert
val expected = SaveWalletAccountsResponse(
accounts = listOf(
WalletAccountDTO(
id = accountList.mainAccount.accountId.value,
name = accountList.mainAccount.accountName.value,
derivationIndex = accountList.mainAccount.derivationIndex.value,
icon = accountList.mainAccount.icon.value.name,
iconColor = accountList.mainAccount.icon.color.name,
),
),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensGroupType
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TokensGroupTypeConverterTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = TokensGroupTypeConverter.convert(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertModel> {
return listOf(
ConvertModel(
value = UserTokensResponse.GroupType.NETWORK,
expected = TokensGroupType.NETWORK,
),
ConvertModel(
value = UserTokensResponse.GroupType.NONE,
expected = TokensGroupType.NONE,
),
ConvertModel(
value = UserTokensResponse.GroupType.TOKEN,
expected = TokensGroupType.NONE,
),
)
}
}
data class ConvertModel(
val value: UserTokensResponse.GroupType,
val expected: TokensGroupType,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = TokensGroupTypeConverter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
private fun provideTestModels(): List<ConvertBackModel> {
return listOf(
ConvertBackModel(
value = TokensGroupType.NETWORK,
expected = UserTokensResponse.GroupType.NETWORK,
),
ConvertBackModel(
value = TokensGroupType.NONE,
expected = UserTokensResponse.GroupType.NONE,
),
)
}
}
data class ConvertBackModel(
val value: TokensGroupType,
val expected: UserTokensResponse.GroupType,
)
}

View file

@ -0,0 +1,81 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensSortType
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TokensSortTypeConverterTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = TokensSortTypeConverter.convert(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
fun provideTestModels() = listOf(
ConvertModel(
value = UserTokensResponse.SortType.BALANCE,
expected = TokensSortType.BALANCE,
),
ConvertModel(
value = UserTokensResponse.SortType.MANUAL,
expected = TokensSortType.NONE,
),
ConvertModel(
value = UserTokensResponse.SortType.MARKETCAP,
expected = TokensSortType.NONE,
),
)
}
data class ConvertModel(
val value: UserTokensResponse.SortType,
val expected: TokensSortType,
)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = TokensSortTypeConverter.convertBack(model.value)
// Assert
val expected = model.expected
Truth.assertThat(actual).isEqualTo(expected)
}
fun provideTestModels() = listOf(
ConvertBackModel(
value = TokensSortType.BALANCE,
expected = UserTokensResponse.SortType.BALANCE,
),
ConvertBackModel(
value = TokensSortType.NONE,
expected = UserTokensResponse.SortType.MANUAL,
),
)
}
data class ConvertBackModel(
val value: TokensSortType,
val expected: UserTokensResponse.SortType,
)
}

View file

@ -0,0 +1,664 @@
package com.tangem.data.account.repository
import arrow.core.None
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.account.converter.*
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletArchivedAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.datastore.RuntimeStateStore
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.models.account.Account.CryptoPortfolio
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 com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
import kotlin.time.Duration.Companion.minutes
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultAccountsCRUDRepositoryTest {
private val tangemTechApi: TangemTechApi = mockk()
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
private val accountsResponseStore: AccountsResponseStore = mockk()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory = mockk()
private val archivedAccountsInnerStore = RuntimeStateStore<List<ArchivedAccount>?>(defaultValue = null)
private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore)
private val userWalletsStore: UserWalletsStore = mockk()
private val convertersContainer: AccountConverterFactoryContainer = mockk()
private val accountListConverter: AccountListConverter = mockk()
private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk()
private val repository = DefaultAccountsCRUDRepository(
tangemTechApi = tangemTechApi,
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = archivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
convertersContainer = convertersContainer,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("011")
@BeforeAll
fun setup() {
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
every { convertersContainer.createAccountListConverter(userWalletId) } returns accountListConverter
every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns cryptoPortfolioConverter
}
@BeforeEach
fun setupEach() {
every { archivedAccountsStoreFactory.create(userWalletId) } returns archivedAccountsStore
}
@AfterEach
fun tearDownEach() {
accountsResponseStoreFlow.value = null
archivedAccountsInnerStore.clear()
clearMocks(
tangemTechApi,
archivedAccountsStoreFactory,
accountListConverter,
cryptoPortfolioConverter,
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetAccountListSync {
@Test
fun `getAccounts should return None when account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val actual = repository.getAccountListSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { accountListConverter.convert(value = any()) }
}
@Test
fun `getAccounts should return AccountList when account list response is not null`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse>()
val accountList = mockk<AccountList>()
accountsResponseStoreFlow.value = response
every { accountListConverter.convert(response) } returns accountList
// Act
val actual = repository.getAccountListSync(userWalletId)
// Assert
val expected = accountList.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createAccountListConverter(userWalletId = userWalletId)
accountListConverter.convert(response)
}
}
@Test
fun `getAccounts should throw exception if converter throws exception`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse>()
mockk<AccountList>()
accountsResponseStoreFlow.value = response
val exception = Exception("Test error")
every { accountListConverter.convert(response) } throws exception
// Act
val actual = runCatching { repository.getAccountListSync(userWalletId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createAccountListConverter(userWalletId = userWalletId)
accountListConverter.convert(response)
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetAccountSync {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `getAccount should return None when account response is null`() = runTest {
// Arrange
val response = null
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) }
}
@Test
fun `getAccount should return None when accountDto is not found`() = runTest {
// Arrange
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns emptyList()
}
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
verify(inverse = true) { convertersContainer.createCryptoPortfolioConverter(userWalletId = any()) }
}
@Test
fun `getAccount should return Account_CryptoPortfolio when account response is not null`() = runTest {
// Arrange
val accountDTO = mockk<WalletAccountDTO> {
every { this@mockk.id } returns accountId.value
}
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
accountsResponseStoreFlow.value = response
val cryptoPortfolio = mockk<CryptoPortfolio>()
every { cryptoPortfolioConverter.convert(accountDTO) } returns cryptoPortfolio
// Act
val actual = repository.getAccountSync(accountId)
// Assert
val expected = cryptoPortfolio.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createCryptoPortfolioConverter(userWalletId)
cryptoPortfolioConverter.convert(accountDTO)
}
}
@Test
fun `getAccount should throw exception if converter throws exception`() = runTest {
// Arrange
val accountDTO = mockk<WalletAccountDTO> {
every { this@mockk.id } returns accountId.value
}
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
accountsResponseStoreFlow.value = response
val exception = Exception("Test error")
every { cryptoPortfolioConverter.convert(accountDTO) } throws exception
// Act
val actual = runCatching { repository.getAccountSync(accountId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
convertersContainer.createCryptoPortfolioConverter(userWalletId)
cryptoPortfolioConverter.convert(accountDTO)
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccountSync {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `getArchivedAccount should return None when archived accounts are null`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should return None when archived account not found`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = listOf())
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should return ArchivedAccount when found`() = runTest {
// Arrange
val archivedAccount = ArchivedAccount(
accountId = accountId,
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex.Main,
tokensCount = 0,
networksCount = 0,
)
archivedAccountsInnerStore.store(value = listOf(archivedAccount))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountSync(accountId)
// Assert
val expected = archivedAccount.toOption()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccount should throws exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccountSync(accountId) }.exceptionOrNull()!!
// Assert
val expected = exception
Truth.assertThat(actual).isSameInstanceAs(expected)
Truth.assertThat(actual).hasMessageThat().isEqualTo(expected.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccountListSync {
@Test
fun `getArchivedAccountListSync should return None when archived accounts are null`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
// Act
val actual = repository.getArchivedAccountListSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccountListSync should return Option with list when archived accounts exist`() = runTest {
// Arrange
val archivedAccount1 = mockk<ArchivedAccount>()
val archivedAccount2 = mockk<ArchivedAccount>()
val archivedAccounts = listOf(archivedAccount1, archivedAccount2)
archivedAccountsInnerStore.store(value = archivedAccounts)
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
// Act
val actual = repository.getArchivedAccountListSync(userWalletId)
// Assert
val expected = archivedAccounts.toOption()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.getSyncOrNull()
}
}
@Test
fun `getArchivedAccountListSync should throw exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccountListSync(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetArchivedAccounts {
@Test
fun `getArchivedAccounts should emit empty list when no archived accounts`() = runTest {
// Arrange
archivedAccountsInnerStore.store(value = null)
val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId)
// Act
val actual = getEmittedValues(archivedAccountsFlow)
// Assert
Truth.assertThat(actual).isEmpty()
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.get()
}
}
@Test
fun `getArchivedAccounts should emit list of archived accounts when present`() = runTest {
// Arrange
val archivedAccount1 = mockk<ArchivedAccount>()
val archivedAccount2 = mockk<ArchivedAccount>()
val archivedAccounts = listOf(archivedAccount1, archivedAccount2)
archivedAccountsInnerStore.store(value = archivedAccounts)
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() + 3.minutes.inWholeMicroseconds)
val archivedAccountsFlow = repository.getArchivedAccounts(userWalletId)
// Act
val actual = getEmittedValues(archivedAccountsFlow)
// Assert
val expected = listOf(archivedAccounts)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
coVerifyOrder {
archivedAccountsStoreFactory.create(userWalletId)
archivedAccountsStore.get()
}
}
@Test
fun `getArchivedAccounts should throw exception when store throws exception`() = runTest {
// Arrange
val exception = Exception("Test error")
coEvery { archivedAccountsStoreFactory.create(userWalletId) } throws exception
// Act
val actual = runCatching { repository.getArchivedAccounts(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder { archivedAccountsStoreFactory.create(userWalletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class FetchArchivedAccounts {
private val accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main)
@Test
fun `fetchArchivedAccounts should store archived accounts in store`() = runTest {
// Arrange
val accountDTO = WalletAccountDTO(
id = accountId.value,
name = "Archived Account",
derivationIndex = 0,
icon = CryptoPortfolioIcon.Icon.Wallet.name,
iconColor = CryptoPortfolioIcon.Color.DullLavender.name,
totalNetworks = 0,
totalTokens = 0,
)
val apiResponse = mockk<GetWalletArchivedAccountsResponse> {
every { this@mockk.accounts } returns listOf(accountDTO)
}
val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO)
coEvery {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue)
} returns ApiResponse.Success(apiResponse)
// Act
repository.fetchArchivedAccounts(userWalletId)
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).containsExactly(archivedAccount)
coVerifyOrder {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue)
archivedAccountsStoreFactory.create(userWalletId)
}
}
@Test
fun `fetchArchivedAccounts should throw exception if API returns error`() = runTest { // Arrange
val exception = Exception("API error")
coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) } throws exception
// Act
val actual = runCatching { repository.fetchArchivedAccounts(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isSameInstanceAs(exception)
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull()
coVerify { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class SaveAccounts {
private val version = 1
@Test
fun `saveAccounts should call API and update store`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.version } returns version
}
accountsResponseStoreFlow.value = accountsResponse
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
val apiResponse = ApiResponse.Success(Unit)
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
ifMatch = version.toString(),
body = body,
)
} returns apiResponse
val converter = mockk<GetWalletAccountsResponseConverter> {
every { this@mockk.convert(accountList) } returns accountsResponse
}
every {
convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet, version = version)
} returns converter
coEvery { accountsResponseStore.updateData(transform = any()) } returns accountsResponse
// Act
repository.saveAccounts(accountList)
// Assert
Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse)
coVerifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body)
convertersContainer.getWalletAccountsResponseCF.create(userWallet, version)
converter.convert(accountList)
accountsResponseStore.updateData(any())
}
}
@Test
fun `saveAccounts if API request is failed`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.version } returns version
}
accountsResponseStoreFlow.value = accountsResponse
val body = SaveWalletAccountsResponseConverter.convert(value = accountList)
val apiResponse = ApiResponse.Error(cause = ApiResponseError.NetworkException) as ApiResponse<Unit>
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
ifMatch = version.toString(),
body = body,
)
} returns apiResponse
// Act
val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isEqualTo(ApiResponseError.NetworkException)
coVerifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
tangemTechApi.saveWalletAccounts(userWalletId.stringValue, version.toString(), body)
}
coVerify(inverse = true) {
convertersContainer.getWalletAccountsResponseCF.create(any(), any())
accountsResponseStore.updateData(any())
}
}
}
}

View file

@ -0,0 +1,91 @@
package com.tangem.data.account.store
import android.content.Context
import com.google.common.truth.Truth
import com.squareup.moshi.Moshi
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AccountsResponseStoreFactoryTest {
private val context: Context = mockk()
private val moshi: Moshi = Moshi.Builder().build()
private val factory: AccountsResponseStoreFactory = AccountsResponseStoreFactory(
context = context,
moshi = moshi,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@AfterEach
fun setup() {
clearMocks(context)
factory.clearStores()
}
@Test
fun `creates new data store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("011")
val createdStore = factory.create(userWalletId = userWalletId)
// Actual
val actual = factory.getAllStores()
// Assert
Truth.assertThat(actual).containsExactly(userWalletId, createdStore)
}
@Test
fun `reuses existing data store for same userWalletId`() {
val userWalletId = UserWalletId("011")
// Arrange (first creation)
val firstStore = factory.create(userWalletId = userWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(userWalletId, firstStore)
// Arrange (second creation)
val secondStore = factory.create(userWalletId = userWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
Truth.assertThat(actual2).containsExactly(userWalletId, secondStore)
Truth.assertThat(firstStore).isSameInstanceAs(secondStore)
}
@Test
fun `creates separate data stores for different userWalletIds`() {
// Arrange (first creation)
val firstWalletId = UserWalletId("011")
val firstStore = factory.create(userWalletId = firstWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore)
// Arrange (second creation)
val secondWalletId = UserWalletId("011")
val secondStore = factory.create(userWalletId = secondWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore)
Truth.assertThat(actual2).containsExactlyEntriesIn(expected)
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.domain.models.wallet.UserWalletId
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreFactoryTest {
private val factory = ArchivedAccountsStoreFactory
@AfterEach
fun tearDownEach() {
factory.clearStores()
}
@Test
fun `creates new store for unique userWalletId`() {
// Arrange
val userWalletId = UserWalletId("001")
val createdStore = factory.create(userWalletId)
// Act
val actual = factory.getAllStores()
// Assert
Truth.assertThat(actual).containsExactly(userWalletId, createdStore)
}
@Test
fun `reuses existing data store for same userWalletId`() {
val userWalletId = UserWalletId("011")
// Arrange (first creation)
val firstStore = factory.create(userWalletId = userWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(userWalletId, firstStore)
// Arrange (second creation)
val secondStore = factory.create(userWalletId = userWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
Truth.assertThat(actual2).containsExactly(userWalletId, secondStore)
Truth.assertThat(firstStore).isSameInstanceAs(secondStore)
}
@Test
fun `creates separate data stores for different userWalletIds`() {
// Arrange (first creation)
val firstWalletId = UserWalletId("011")
val firstStore = factory.create(userWalletId = firstWalletId)
// Act (first creation)
val actual1 = factory.getAllStores()
// Assert (first creation)
Truth.assertThat(actual1).containsExactly(firstWalletId, firstStore)
// Arrange (second creation)
val secondWalletId = UserWalletId("011")
val secondStore = factory.create(userWalletId = secondWalletId)
// Act (second creation)
val actual2 = factory.getAllStores()
// Assert (second creation)
val expected = mapOf(firstWalletId to firstStore, secondWalletId to secondStore)
Truth.assertThat(actual2).containsExactlyEntriesIn(expected)
}
}

View file

@ -0,0 +1,139 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
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 com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import kotlin.time.Duration.Companion.seconds
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ArchivedAccountsStoreTest {
private val runtimeStore: RuntimeStateStore<List<ArchivedAccount>?> = RuntimeStateStore(defaultValue = null)
private val archivedAccountsStore: ArchivedAccountsStore = ArchivedAccountsStore(runtimeStore = runtimeStore)
@AfterEach
fun tearDown() {
archivedAccountsStore.clear()
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Get {
@Test
fun `get returns empty flow`() = runTest {
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
val expected = listOf(archivedAccount)
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = getEmittedValues(archivedAccountsStore.get())
// Assert
Truth.assertThat(actual).isEmpty() // nothing emmited
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetSyncOrnNull {
@Test
fun `getSyncOrNull returns null`() = runTest {
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
@Test
fun `get returns flow with not expired data`() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
archivedAccountsStore.store(value = listOf(archivedAccount))
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
@Test
fun `get returns flow with expired data`() = runTest {
// Arrange
archivedAccountsStore.store(value = listOf(createArchivedAccount()))
archivedAccountsStore.setTimestamp(time = System.currentTimeMillis() - 120.seconds.inWholeMicroseconds)
// Act
val actual = archivedAccountsStore.getSyncOrNull()
// Assert
Truth.assertThat(actual).isNull()
}
}
@Test
fun store() = runTest {
// Arrange
val archivedAccount = createArchivedAccount()
// Act
archivedAccountsStore.store(value = listOf(archivedAccount))
val actual = runtimeStore.getSyncOrNull()
// Assert
val expected = archivedAccount
Truth.assertThat(actual).containsExactly(expected)
}
private fun createArchivedAccount(): ArchivedAccount {
return ArchivedAccount(
accountId = AccountId.forCryptoPortfolio(
userWalletId = UserWalletId("011"),
derivationIndex = DerivationIndex.Main,
),
name = AccountName("Archived Account").getOrNull()!!,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = DerivationIndex.Main,
tokensCount = 2,
networksCount = 1,
)
}
}

View file

@ -2,8 +2,9 @@ package com.tangem.data.common.currency
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.currency.CryptoCurrency
import javax.inject.Inject
class UserTokensResponseFactory {
class UserTokensResponseFactory @Inject constructor() {
fun createUserTokensResponse(
currencies: List<CryptoCurrency>,

View file

@ -37,6 +37,7 @@ import com.tangem.utils.coroutines.runCatching
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.collections.mutableSetOf
typealias SeedPhraseNotificationsStatuses = Map<UserWalletId, SeedPhraseNotificationsStatus>
@ -50,6 +51,9 @@ internal class DefaultWalletsRepository(
private val authProvider: AuthProvider,
) : WalletsRepository {
private val upgradeWalletNotificationDisabled: MutableStateFlow<Set<UserWalletId>> =
MutableStateFlow(mutableSetOf<UserWalletId>())
override suspend fun shouldSaveUserWalletsSync(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SAVE_USER_WALLETS_KEY, default = false)
}
@ -333,6 +337,16 @@ internal class DefaultWalletsRepository(
}
}
override fun isUpgradeWalletNotificationEnabled(userWalletId: UserWalletId): Flow<Boolean> {
return upgradeWalletNotificationDisabled.map {
it.contains(userWalletId)
}
}
override suspend fun dismissUpgradeWalletNotification(userWalletId: UserWalletId) {
upgradeWalletNotificationDisabled.update { it.plus(userWalletId) }
}
override suspend fun setWalletName(walletId: String, walletName: String) = withContext(dispatchers.io) {
tangemTechApi.updateWallet(
walletId = walletId,

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.HotMapDerivationsRepository
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.domain.wallets.usecase.BackendId
import com.tangem.hot.sdk.model.DeriveWalletRequest
import com.tangem.operations.derivation.ExtendedPublicKeysMap

View file

@ -3,7 +3,7 @@ package com.tangem.data.wallets.hot
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.copy
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.hot.sdk.TangemHotSdk
@ -11,22 +11,24 @@ import com.tangem.hot.sdk.exception.WrongPasswordException
import com.tangem.hot.sdk.model.*
import javax.inject.Inject
class HotWalletAccessor @Inject constructor(
class DefaultHotWalletAccessor @Inject constructor(
private val tangemHotSdk: TangemHotSdk,
private val userWalletsListRepository: UserWalletsListRepository,
private val hotWalletPasswordRequester: HotWalletPasswordRequester,
private val walletsRepository: WalletsRepository,
) {
) : HotWalletAccessor {
suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
override suspend fun signHashes(hotWalletId: HotWalletId, dataToSign: List<DataToSign>): List<SignedData> =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.signHashes(unlockHotWallet = unlock, dataToSign = dataToSign)
}
suspend fun derivePublicKeys(hotWalletId: HotWalletId, request: DeriveWalletRequest): DerivedPublicKeyResponse =
hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request)
}
override suspend fun derivePublicKeys(
hotWalletId: HotWalletId,
request: DeriveWalletRequest,
): DerivedPublicKeyResponse = hotSdkRequest(hotWalletId) { unlock ->
tangemHotSdk.derivePublicKey(unlockHotWallet = unlock, request = request)
}
private suspend fun <T> hotSdkRequest(hotWalletId: HotWalletId, block: suspend (unlock: UnlockHotWallet) -> T): T {
val isAccessCodeRequired = walletsRepository.requireAccessCode()

View file

@ -6,6 +6,7 @@ import com.tangem.common.CompletionResult
import com.tangem.common.core.TangemSdkError
import com.tangem.common.map
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.hot.HotWalletAccessor
import com.tangem.hot.sdk.model.DataToSign
import com.tangem.operations.sign.SignData
import dagger.assisted.Assisted