Updated on 2026-08-14

This commit is contained in:
Tangem 2025-08-19 16:14:54 +04:00
parent 35fd75485c
commit 3e01845203
21 changed files with 1330 additions and 2 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
@ -21,7 +26,7 @@ dependencies {
// endregion
// Project - Data
implementation(projects.core.datasource)
implementation(projects.data.common)
// endregion
// region DI
@ -34,4 +39,13 @@ dependencies {
implementation(deps.kotlin.coroutines)
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,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

@ -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

@ -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

@ -1,5 +1,8 @@
package com.tangem.domain.models.account
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import com.tangem.common.extensions.toByteArray
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.extensions.toHexString
@ -18,9 +21,39 @@ data class AccountId private constructor(
val userWalletId: UserWalletId,
) {
sealed interface Error {
val tag: String
get() = this::class.simpleName ?: "AccountId.Error"
data object Empty : Error {
override fun toString(): String = "$tag: Account ID cannot be blank"
}
data object InvalidFormat : Error {
override fun toString(): String = "$tag: Account ID must be a 64-character hexadecimal string"
}
}
companion object {
private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") }
private val hexRegex = Regex("^[a-fA-F0-9]{64}$")
/**
* Creates a unique account identifier for a crypto portfolio
*
* @param userWalletId the identifier of the user wallet
* @param value the unique string value representing the account
*
* @return an [Either] containing the [AccountId] on success, or an [Error] on failure
*/
fun forCryptoPortfolio(userWalletId: UserWalletId, value: String): Either<Error, AccountId> = either {
ensure(value.isNotBlank()) { Error.Empty }
ensure(value.matches(hexRegex)) { Error.InvalidFormat }
AccountId(value = value, userWalletId = userWalletId)
}
/**
* Creates a unique account identifier for a crypto portfolio