Updated on 2026-08-14

This commit is contained in:
Tangem 2025-09-11 16:16:44 +04:00
parent 61f2412cb5
commit e3ec61bc4b
11 changed files with 509 additions and 8 deletions

View file

@ -19,6 +19,9 @@ internal class RuntimeUserWalletsStore(
override val userWallets: Flow<List<UserWallet>>
get() = userWalletsListManager.userWallets
override val userWalletsSync: List<UserWallet>
get() = userWalletsListManager.userWalletsSync
override fun getSyncOrNull(key: UserWalletId): UserWallet? {
return userWalletsListManager.userWalletsSync.firstOrNull { it.walletId == key }
}

View file

@ -3,9 +3,9 @@ package com.tangem.tap.data
import com.tangem.common.CompletionResult
import com.tangem.common.catching
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.core.wallets.UserWalletsListRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ -24,6 +24,9 @@ class UserWalletsStoreRepositoryProxy(
}
}
override val userWalletsSync: List<UserWallet>
get() = userWalletsListRepository.userWallets.value.orEmpty()
override fun getSyncOrNull(key: UserWalletId): UserWallet? {
return userWalletsListRepository.userWallets.value?.find { it.walletId == key }
}

View file

@ -1,5 +1,6 @@
package com.tangem.tap.di.domain
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.usecase.*
import dagger.Module
@ -49,4 +50,16 @@ internal object AccountDomainModule {
): GetUnoccupiedAccountIndexUseCase {
return GetUnoccupiedAccountIndexUseCase(crudRepository = accountsCRUDRepository)
}
@Provides
@Singleton
fun provideIsAccountsModeEnabledUseCase(
accountsCRUDRepository: AccountsCRUDRepository,
accountsFeatureToggles: AccountsFeatureToggles,
): IsAccountsModeEnabledUseCase {
return IsAccountsModeEnabledUseCase(
crudRepository = accountsCRUDRepository,
accountsFeatureToggles = accountsFeatureToggles,
)
}
}

View file

@ -15,6 +15,8 @@ interface UserWalletsStore {
val userWallets: Flow<List<UserWallet>>
val userWalletsSync: List<UserWallet>
fun getSyncOrNull(key: UserWalletId): UserWallet?
fun getSyncStrict(key: UserWalletId): UserWallet

View file

@ -25,6 +25,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
/**
@ -108,7 +109,7 @@ internal class DefaultAccountsCRUDRepository(
walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse)
}
override suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option<Int> = option {
override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
ensureNotNull(accountListResponse)
@ -116,10 +117,19 @@ internal class DefaultAccountsCRUDRepository(
return accountListResponse.wallet.totalAccounts.toOption()
}
override fun getTotalAccountsCount(userWalletId: UserWalletId): Flow<Option<Int>> {
return getAccountsResponseStore(userWalletId = userWalletId).data
.map { it?.wallet?.totalAccounts.toOption() }
}
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return userWalletsStore.getSyncStrict(userWalletId)
}
override fun getUserWallets(): Flow<List<UserWallet>> = userWalletsStore.userWallets
override fun getUserWalletsSync(): List<UserWallet> = userWalletsStore.userWalletsSync
private suspend fun getETag(userWalletId: UserWalletId): String? {
return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
}

View file

@ -648,4 +648,94 @@ class DefaultAccountsCRUDRepositoryTest {
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetTotalAccountsCountSync {
@Test
fun `getTotalAccountsCountSync returns None if account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val actual = repository.getTotalAccountsCountSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
}
@Test
fun `getTotalAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest {
// Arrange
val totalAccounts = 5
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.totalAccounts } returns totalAccounts
}
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getTotalAccountsCountSync(userWalletId)
// Assert
val expected = totalAccounts.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetTotalAccountsCount {
@Test
fun `getTotalAccountsCount emits 0 when account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val flow = repository.getTotalAccountsCount(userWalletId)
val actual = getEmittedValues(flow)
// Assert
Truth.assertThat(actual).containsExactly(None)
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
}
@Test
fun `getTotalAccountsCount emits correct value when response is valid`() = runTest {
// Arrange
val totalAccounts = 7
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.totalAccounts } returns totalAccounts
}
accountsResponseStoreFlow.value = response
// Act
val flow = repository.getTotalAccountsCount(userWalletId)
val actual = getEmittedValues(flow)
// Assert
Truth.assertThat(actual).containsExactly(totalAccounts.toOption())
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
}
}
}

View file

@ -73,7 +73,14 @@ interface AccountsCRUDRepository {
*
* @param userWalletId the unique identifier of the user wallet
*/
suspend fun getTotalAccountsCount(userWalletId: UserWalletId): Option<Int>
suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int>
/**
* Provides a flow of the total count of accounts associated with a specific user wallet including archived accounts
*
* @param userWalletId the unique identifier of the user wallet
*/
fun getTotalAccountsCount(userWalletId: UserWalletId): Flow<Option<Int>>
/**
* Retrieves a user wallet by its unique identifier
@ -82,4 +89,10 @@ interface AccountsCRUDRepository {
* @return the [UserWallet] associated with the given identifier
*/
fun getUserWallet(userWalletId: UserWalletId): UserWallet
/** Provides a flow of all user wallets */
fun getUserWallets(): Flow<List<UserWallet>>
/** Synchronously retrieves all user wallets */
fun getUserWalletsSync(): List<UserWallet>
}

View file

@ -35,7 +35,7 @@ class GetUnoccupiedAccountIndexUseCase(
private suspend fun Raise<Error>.getTotalAccountsCount(userWalletId: UserWalletId): Int {
return catch(
block = { crudRepository.getTotalAccountsCount(userWalletId = userWalletId) },
block = { crudRepository.getTotalAccountsCountSync(userWalletId = userWalletId) },
catch = { raise(Error.DataOperationFailed(cause = it)) },
)
.getOrElse { raise(Error.DataNotFound) }

View file

@ -0,0 +1,66 @@
package com.tangem.domain.account.usecase
import arrow.core.Option
import arrow.core.getOrElse
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
/**
* Use case to determine if the accounts mode is enabled.
* Accounts mode is considered enabled if there are at least two accounts in any of the user wallets that support
* multiple currencies.
*
* @property crudRepository repository to interact with user wallets and their accounts
*
[REDACTED_AUTHOR]
*/
class IsAccountsModeEnabledUseCase(
private val crudRepository: AccountsCRUDRepository,
private val accountsFeatureToggles: AccountsFeatureToggles,
) {
@OptIn(ExperimentalCoroutinesApi::class)
operator fun invoke(): Flow<Boolean> {
if (!accountsFeatureToggles.isFeatureEnabled) return flowOf(value = false)
return crudRepository.getUserWallets()
.flatMapLatest { userWallets ->
val totalAccountsCountList = getTotalAccountsCountList(userWallets)
combine(flows = totalAccountsCountList) { it.toList().isModeEnabled() }
}
.onEmpty { emit(false) }
}
suspend fun invokeSync(): Boolean {
if (!accountsFeatureToggles.isFeatureEnabled) return false
return crudRepository.getUserWalletsSync()
.map { userWallet ->
// If the wallet does not support multiple currencies, we consider its account count as 0
if (!userWallet.isMultiCurrency) return@map 0
crudRepository.getTotalAccountsCountSync(userWalletId = userWallet.walletId).getOrZero()
}
.isModeEnabled()
}
private fun getTotalAccountsCountList(userWallets: List<UserWallet>): List<Flow<Int>> {
return userWallets
.map { userWallet ->
// If the wallet does not support multiple currencies, we consider its account count as 0
if (!userWallet.isMultiCurrency) return@map flowOf(0)
crudRepository.getTotalAccountsCount(userWalletId = userWallet.walletId)
.map { maybeCount -> maybeCount.getOrZero() }
}
}
private fun Option<Int>.getOrZero(): Int = getOrElse { 0 }
private fun List<Int>.isModeEnabled(): Boolean = any { it >= 2 }
}

View file

@ -30,7 +30,7 @@ class GetUnoccupiedAccountIndexUseCaseTest {
@Test
fun `invoke should return next unoccupied index when repository returns count`() = runTest {
// Arrange
coEvery { crudRepository.getTotalAccountsCount(userWalletId) } returns 3.toOption()
coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } returns 3.toOption()
// Act
val actual = useCase(userWalletId = userWalletId)
@ -39,14 +39,14 @@ class GetUnoccupiedAccountIndexUseCaseTest {
val expected = DerivationIndex(4)
Truth.assertThat(actual).isEqualTo(expected)
coVerify { crudRepository.getTotalAccountsCount(userWalletId) }
coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) }
}
@Test
fun `invoke should return error if repository throws exception`() = runTest {
// Arrange
val exception = IllegalStateException("Test error")
coEvery { crudRepository.getTotalAccountsCount(userWalletId) } throws exception
coEvery { crudRepository.getTotalAccountsCountSync(userWalletId) } throws exception
// Act
val actual = useCase(userWalletId = userWalletId)
@ -55,6 +55,6 @@ class GetUnoccupiedAccountIndexUseCaseTest {
val expected = GetUnoccupiedAccountIndexUseCase.Error.DataOperationFailed(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerify { crudRepository.getTotalAccountsCount(userWalletId) }
coVerify { crudRepository.getTotalAccountsCountSync(userWalletId) }
}
}

View file

@ -0,0 +1,301 @@
package com.tangem.domain.account.usecase
import arrow.core.none
import arrow.core.some
import com.google.common.truth.Truth
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import io.mockk.*
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
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
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class IsAccountsModeEnabledUseCaseTest {
private val accountsCRUDRepository: AccountsCRUDRepository = mockk()
private val featureToggles: AccountsFeatureToggles = mockk()
private val useCase = IsAccountsModeEnabledUseCase(accountsCRUDRepository, featureToggles)
@AfterEach
fun tearDown() {
clearMocks(accountsCRUDRepository, featureToggles)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Invoke {
@Test
fun `returns false when feature is disabled`() = runTest {
// Arrange
every { featureToggles.isFeatureEnabled } returns false
// Act
val actual = useCase.invoke().firstOrNull()
// Assert
Truth.assertThat(actual).isFalse()
verify(exactly = 1) { featureToggles.isFeatureEnabled }
verify(inverse = true) { accountsCRUDRepository.getUserWallets() }
}
@Test
fun `returns false when getUserWallets emits empty flow`() = runTest {
// Arrange
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWallets() } returns emptyFlow()
// Act
val actual = useCase.invoke().firstOrNull()
// Assert
Truth.assertThat(actual).isFalse()
verifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWallets()
}
verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) }
}
@Test
fun `returns false when getUserWallets emits one wallet with isMultiCurrency false`() = runTest {
// Arrange
val wallet = createUserWallet(isMultiCurrency = false)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet))
// Act
val actual = useCase.invoke().first()
// Assert
Truth.assertThat(actual).isFalse()
verifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWallets()
}
verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(any()) }
}
@Test
fun `returns true when getUserWallets emits one wallet with isMultiCurrency true`() = runTest {
// Arrange
val wallet = createUserWallet(isMultiCurrency = true)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet))
every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(2.some())
// Act
val actual = useCase.invoke().first()
// Assert
Truth.assertThat(actual).isTrue()
verifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWallets()
accountsCRUDRepository.getTotalAccountsCount(wallet.walletId)
}
}
@Test
fun `returns false when getUserWallets emits one wallet with isMultiCurrency true and None counts`() = runTest {
// Arrange
val wallet = createUserWallet(isMultiCurrency = true)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet))
every { accountsCRUDRepository.getTotalAccountsCount(wallet.walletId) } returns flowOf(none())
// Act
val actual = useCase.invoke().first()
// Assert
Truth.assertThat(actual).isFalse()
verifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWallets()
accountsCRUDRepository.getTotalAccountsCount(wallet.walletId)
}
}
@Test
fun `returns true when getUserWallets emits two wallets, one isMultiCurrency false, one true`() = runTest {
// Arrange
val wallet1 = createUserWallet(isMultiCurrency = false)
val wallet2 = createUserWallet(isMultiCurrency = true)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWallets() } returns flowOf(listOf(wallet1, wallet2))
every { accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId) } returns flowOf(2.some())
// Act
val actual = useCase.invoke().first()
// Assert
Truth.assertThat(actual).isTrue()
verifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWallets()
accountsCRUDRepository.getTotalAccountsCount(wallet2.walletId)
}
verify(inverse = true) { accountsCRUDRepository.getTotalAccountsCount(wallet1.walletId) }
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class InvokeSync {
@Test
fun `returns false when feature is disabled`() = runTest {
// Arrange
every { featureToggles.isFeatureEnabled } returns false
// Act
val actual = useCase.invokeSync()
// Assert
Truth.assertThat(actual).isFalse()
verify(exactly = 1) { featureToggles.isFeatureEnabled }
verify(inverse = true) { accountsCRUDRepository.getUserWalletsSync() }
}
@Test
fun `returns false when getUserWalletsSync returns empty list`() = runTest {
// Arrange
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWalletsSync() } returns emptyList()
// Act
val actual = useCase.invokeSync()
// Assert
Truth.assertThat(actual).isFalse()
verifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWalletsSync()
}
coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) }
}
@Test
fun `returns false when getUserWalletsSync returns one wallet with isMultiCurrency false`() = runTest {
// Arrange
val wallet = createUserWallet(isMultiCurrency = false)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet)
// Act
val actual = useCase.invokeSync()
// Assert
Truth.assertThat(actual).isFalse()
verifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWalletsSync()
}
coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(any()) }
}
@Test
fun `returns true when getUserWalletsSync returns one wallet with isMultiCurrency true`() = runTest {
// Arrange
val wallet = createUserWallet(isMultiCurrency = true)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet)
coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns 2.some()
// Act
val actual = useCase.invokeSync()
// Assert
Truth.assertThat(actual).isTrue()
coVerifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWalletsSync()
accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId)
}
}
@Test
fun `returns false when getUserWalletsSync returns multi wallet with None counts`() = runTest {
// Arrange
val wallet = createUserWallet(isMultiCurrency = true)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet)
coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId) } returns none()
// Act
val actual = useCase.invokeSync()
// Assert
Truth.assertThat(actual).isFalse()
coVerifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWalletsSync()
accountsCRUDRepository.getTotalAccountsCountSync(wallet.walletId)
}
}
@Test
fun `returns true when getUserWalletsSync returns multi and single wallets`() = runTest {
// Arrange
val wallet1 = createUserWallet(isMultiCurrency = false)
val wallet2 = createUserWallet(isMultiCurrency = true)
every { featureToggles.isFeatureEnabled } returns true
every { accountsCRUDRepository.getUserWalletsSync() } returns listOf(wallet1, wallet2)
coEvery { accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId) } returns 2.some()
// Act
val actual = useCase.invokeSync()
// Assert
Truth.assertThat(actual).isTrue()
coVerifyOrder {
featureToggles.isFeatureEnabled
accountsCRUDRepository.getUserWalletsSync()
accountsCRUDRepository.getTotalAccountsCountSync(wallet2.walletId)
}
coVerify(inverse = true) { accountsCRUDRepository.getTotalAccountsCountSync(wallet1.walletId) }
}
}
private fun createUserWallet(isMultiCurrency: Boolean): UserWallet = mockk {
every { this@mockk.walletId } returns UserWalletId(stringValue = "011")
every { this@mockk.isMultiCurrency } returns isMultiCurrency
}
}