From 2dadb60975b3d64c8cb2b1ae5c950949a0e281d3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Jul 2026 11:50:47 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/WalletsDomainModule.kt | 14 +- .../di/UserWalletDataCleanerModule.kt | 15 ++ .../di/TxHistoryItemsStoreModule.kt | 42 +++++- .../DefaultTangemPayTxHistoryItemsStore.kt | 36 +++-- .../visa/TangemPayTxHistoryItemsStore.kt | 2 + .../visa/entity/TangemPayTxHistoryItemDM.kt | 102 ++++++++++++++ .../TangemPayTxHistoryItemDMConverter.kt | 133 ++++++++++++++++++ ...DefaultTangemPayTxHistoryItemsStoreTest.kt | 119 ++++++++++++++++ ...angemPayTxHistoryStoreSerializationTest.kt | 122 ++++++++++++++++ .../TangemPayTxHistoryItemDMConverterTest.kt | 103 ++++++++++++++ .../pay/TangemPayUserWalletDataCleaner.kt | 18 +++ .../tangem/data/pay/di/TangemPayDataModule.kt | 7 + .../repository/DefaultOnboardingRepository.kt | 3 + .../DefaultTangemPayTxHistoryRepository.kt | 25 ++-- .../pay/store/PaymentAccountStatusesStore.kt | 14 ++ .../pay/TangemPayUserWalletDataCleanerTest.kt | 49 +++++++ .../store/PaymentAccountStatusesStoreTest.kt | 89 ++++++++++++ .../common/wallets/UserWalletDataCleaner.kt | 9 ++ .../wallets/usecase/DeleteWalletUseCase.kt | 22 ++- .../usecase/DeleteWalletUseCaseTest.kt | 128 +++++++++++++++++ .../HotAccessCodeRequestModel.kt | 4 +- .../tangempay/entity/TangemPayDetailsUM.kt | 1 + .../tangempay/model/TangemPayDetailsModel.kt | 19 ++- .../transformers/DetailsBalanceTransformer.kt | 2 + .../tangempay/ui/TangemPayDetailsScreenV2.kt | 37 +++-- .../model/TangemPayDetailsModelTest.kt | 43 +++++- 26 files changed, 1110 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletDataCleanerModule.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDM.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverter.kt create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStoreTest.kt create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/TangemPayTxHistoryStoreSerializationTest.kt create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverterTest.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleaner.kt create mode 100644 data/visa/src/test/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleanerTest.kt create mode 100644 data/visa/src/test/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStoreTest.kt create mode 100644 domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletDataCleaner.kt create mode 100644 domain/wallets/src/test/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCaseTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index f409d2dd20..f6d6857523 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.common.wallets.UserWalletDataCleaner import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.transaction.WalletAddressServiceRepository @@ -25,6 +26,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSy import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.tap.domain.DefaultUserWalletSelectedHandler +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -188,8 +190,16 @@ internal object WalletsDomainModule { @Provides @Singleton - fun providesDeleteWalletUseCase(userWalletsListRepository: UserWalletsListRepository): DeleteWalletUseCase { - return DeleteWalletUseCase(userWalletsListRepository = userWalletsListRepository) + fun providesDeleteWalletUseCase( + userWalletsListRepository: UserWalletsListRepository, + userWalletDataCleaners: Set<@JvmSuppressWildcards UserWalletDataCleaner>, + appCoroutineScope: AppCoroutineScope, + ): DeleteWalletUseCase { + return DeleteWalletUseCase( + userWalletsListRepository = userWalletsListRepository, + userWalletDataCleaners = userWalletDataCleaners, + appCoroutineScope = appCoroutineScope, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletDataCleanerModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletDataCleanerModule.kt new file mode 100644 index 0000000000..0cb2ca7fe6 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletDataCleanerModule.kt @@ -0,0 +1,15 @@ +package com.tangem.tap.domain.userWalletList.di + +import com.tangem.domain.common.wallets.UserWalletDataCleaner +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.Multibinds + +@Module +@InstallIn(SingletonComponent::class) +internal interface UserWalletDataCleanerModule { + + @Multibinds + fun userWalletDataCleaners(): Set +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt index 283ea6c473..1ebe2dfac0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TxHistoryItemsStoreModule.kt @@ -1,14 +1,27 @@ package com.tangem.datasource.di +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile import com.tangem.datasource.local.datastore.RuntimeDataStore import com.tangem.datasource.local.txhistory.DefaultTxHistoryItemsStore import com.tangem.datasource.local.txhistory.TxHistoryItemsStore import com.tangem.datasource.local.visa.DefaultTangemPayTxHistoryItemsStore import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter +import com.tangem.datasource.utils.KotlinxDataStoreSerializer +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer import javax.inject.Singleton @Module @@ -25,9 +38,34 @@ internal object TxHistoryItemsStoreModule { @Provides @Singleton - fun provideTangemPayTxHistoryItemsStore(): TangemPayTxHistoryItemsStore { + fun provideTangemPayTxHistoryItemsStore( + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + toDMConverter: TangemPayTxHistoryItemToDMConverter, + toDomainConverter: TangemPayTxHistoryItemToDomainConverter, + ): TangemPayTxHistoryItemsStore { return DefaultTangemPayTxHistoryItemsStore( - dataStore = RuntimeDataStore(), + dataStore = DataStoreFactory.create( + serializer = KotlinxDataStoreSerializer( + defaultValue = emptyMap(), + serializer = MapSerializer( + keySerializer = String.serializer(), + valueSerializer = MapSerializer( + keySerializer = String.serializer(), + valueSerializer = ListSerializer(TangemPayTxHistoryItemDM.serializer()), + ), + ), + // TangemPayTxHistoryItemDM.Collateral declares a `type` field that would clash with the + // default kotlinx polymorphic class discriminator ("type"), so persist sealed subtypes + // under a non-conflicting discriminator key. + json = KotlinxDataStoreSerializer.jsonBuilder { classDiscriminator = "__type" }, + ), + corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() }, + produceFile = { context.dataStoreFile(fileName = "tangem_pay_tx_history") }, + scope = appScope, + ), + toDMConverter = toDMConverter, + toDomainConverter = toDomainConverter, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt index fd01586877..6fd9686c24 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStore.kt @@ -1,23 +1,37 @@ package com.tangem.datasource.local.visa -import com.tangem.datasource.local.datastore.core.StringKeyDataStore -import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator +import androidx.datastore.core.DataStore +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import kotlinx.coroutines.flow.first + +internal typealias StoredTangemPayTxHistory = Map>> internal class DefaultTangemPayTxHistoryItemsStore( - dataStore: StringKeyDataStore>>, -) : TangemPayTxHistoryItemsStore, - StringKeyDataStoreDecorator>>(dataStore) { - override fun provideStringKey(key: String): String = key + private val dataStore: DataStore, + private val toDMConverter: TangemPayTxHistoryItemToDMConverter, + private val toDomainConverter: TangemPayTxHistoryItemToDomainConverter, +) : TangemPayTxHistoryItemsStore { override suspend fun getSyncOrNull(key: String, cursor: String): List? { - val storedValue = getSyncOrNull(key) - return storedValue?.get(cursor) + return dataStore.data.first()[key]?.get(cursor)?.let { toDomainConverter.convertList(it) } } override suspend fun store(key: String, cursor: String, value: List) { - val oldValue = getSyncOrNull(key).orEmpty() - val newValue = oldValue.toMutableMap().apply { put(cursor, value) } - store(key, newValue) + val page = toDMConverter.convertList(value) + dataStore.updateData { stored -> + val walletPages = stored[key].orEmpty() + stored + (key to walletPages + (cursor to page)) + } + } + + override suspend fun remove(key: String) { + remove(keys = listOf(key)) + } + + override suspend fun remove(keys: List) { + dataStore.updateData { stored -> stored - keys.toSet() } } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt index e18be41c5a..9c0f6e8241 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayTxHistoryItemsStore.kt @@ -8,5 +8,7 @@ interface TangemPayTxHistoryItemsStore { suspend fun remove(key: String) + suspend fun remove(keys: List) + suspend fun store(key: String, cursor: String, value: List) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDM.kt new file mode 100644 index 0000000000..ce01de9ed3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDM.kt @@ -0,0 +1,102 @@ +package com.tangem.datasource.local.visa.entity + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import com.tangem.domain.models.serialization.SerializedCurrency +import com.tangem.domain.models.serialization.SerializedDateTime +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayTxHistoryItemDM { + + abstract val id: String + abstract val date: SerializedDateTime + abstract val amount: SerializedBigDecimal + abstract val currency: SerializedCurrency + abstract val jsonRepresentation: String + + @Serializable + @SerialName("spend") + data class Spend( + @SerialName("id") override val id: String, + @SerialName("json_representation") override val jsonRepresentation: String, + @SerialName("date") override val date: SerializedDateTime, + @SerialName("amount") override val amount: SerializedBigDecimal, + @SerialName("currency") override val currency: SerializedCurrency, + @SerialName("authorized_amount") val authorizedAmount: SerializedBigDecimal, + @SerialName("local_amount") val localAmount: SerializedBigDecimal?, + @SerialName("local_currency") val localCurrency: SerializedCurrency?, + @SerialName("enriched_merchant_name") val enrichedMerchantName: String?, + @SerialName("merchant_name") val merchantName: String, + @SerialName("enriched_merchant_category") val enrichedMerchantCategory: String?, + @SerialName("merchant_category_code") val merchantCategoryCode: String?, + @SerialName("merchant_category") val merchantCategory: String?, + @SerialName("status") val status: Status, + @SerialName("enriched_merchant_icon_url") val enrichedMerchantIconUrl: String?, + @SerialName("declined_reason") val declinedReason: String?, + ) : TangemPayTxHistoryItemDM() + + @Serializable + @SerialName("payment") + data class Payment( + @SerialName("id") override val id: String, + @SerialName("json_representation") override val jsonRepresentation: String, + @SerialName("date") override val date: SerializedDateTime, + @SerialName("amount") override val amount: SerializedBigDecimal, + @SerialName("currency") override val currency: SerializedCurrency, + @SerialName("transaction_hash") val transactionHash: String?, + ) : TangemPayTxHistoryItemDM() + + @Serializable + @SerialName("fee") + data class Fee( + @SerialName("id") override val id: String, + @SerialName("json_representation") override val jsonRepresentation: String, + @SerialName("date") override val date: SerializedDateTime, + @SerialName("amount") override val amount: SerializedBigDecimal, + @SerialName("currency") override val currency: SerializedCurrency, + @SerialName("description") val description: String?, + ) : TangemPayTxHistoryItemDM() + + @Serializable + @SerialName("collateral") + data class Collateral( + @SerialName("id") override val id: String, + @SerialName("json_representation") override val jsonRepresentation: String, + @SerialName("date") override val date: SerializedDateTime, + @SerialName("amount") override val amount: SerializedBigDecimal, + @SerialName("currency") override val currency: SerializedCurrency, + @SerialName("transaction_hash") val transactionHash: String, + @SerialName("type") val type: Type, + ) : TangemPayTxHistoryItemDM() + + @Serializable + enum class Type { + @SerialName("deposit") + Deposit, + + @SerialName("withdrawal") + Withdrawal, + } + + @Serializable + enum class Status { + @SerialName("pending") + PENDING, + + @SerialName("reserved") + RESERVED, + + @SerialName("completed") + COMPLETED, + + @SerialName("declined") + DECLINED, + + @SerialName("reversed") + REVERSED, + + @SerialName("unknown") + UNKNOWN, + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverter.kt new file mode 100644 index 0000000000..534d0d2743 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverter.kt @@ -0,0 +1,133 @@ +package com.tangem.datasource.local.visa.entity + +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.utils.converter.Converter +import javax.inject.Inject + +internal class TangemPayTxHistoryItemToDMConverter @Inject constructor() : + Converter { + + override fun convert(value: TangemPayTxHistoryItem): TangemPayTxHistoryItemDM = when (value) { + is TangemPayTxHistoryItem.Spend -> TangemPayTxHistoryItemDM.Spend( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + authorizedAmount = value.authorizedAmount, + localAmount = value.localAmount, + localCurrency = value.localCurrency, + enrichedMerchantName = value.enrichedMerchantName, + merchantName = value.merchantName, + enrichedMerchantCategory = value.enrichedMerchantCategory, + merchantCategoryCode = value.merchantCategoryCode, + merchantCategory = value.merchantCategory, + status = value.status.toDM(), + enrichedMerchantIconUrl = value.enrichedMerchantIconUrl, + declinedReason = value.declinedReason, + ) + is TangemPayTxHistoryItem.Payment -> TangemPayTxHistoryItemDM.Payment( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + transactionHash = value.transactionHash, + ) + is TangemPayTxHistoryItem.Fee -> TangemPayTxHistoryItemDM.Fee( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + description = value.description, + ) + is TangemPayTxHistoryItem.Collateral -> TangemPayTxHistoryItemDM.Collateral( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + transactionHash = value.transactionHash, + type = value.type.toDM(), + ) + } +} + +internal class TangemPayTxHistoryItemToDomainConverter @Inject constructor() : + Converter { + + override fun convert(value: TangemPayTxHistoryItemDM): TangemPayTxHistoryItem = when (value) { + is TangemPayTxHistoryItemDM.Spend -> TangemPayTxHistoryItem.Spend( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + authorizedAmount = value.authorizedAmount, + localAmount = value.localAmount, + localCurrency = value.localCurrency, + enrichedMerchantName = value.enrichedMerchantName, + merchantName = value.merchantName, + enrichedMerchantCategory = value.enrichedMerchantCategory, + merchantCategoryCode = value.merchantCategoryCode, + merchantCategory = value.merchantCategory, + status = value.status.toDomain(), + enrichedMerchantIconUrl = value.enrichedMerchantIconUrl, + declinedReason = value.declinedReason, + ) + is TangemPayTxHistoryItemDM.Payment -> TangemPayTxHistoryItem.Payment( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + transactionHash = value.transactionHash, + ) + is TangemPayTxHistoryItemDM.Fee -> TangemPayTxHistoryItem.Fee( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + description = value.description, + ) + is TangemPayTxHistoryItemDM.Collateral -> TangemPayTxHistoryItem.Collateral( + id = value.id, + jsonRepresentation = value.jsonRepresentation, + date = value.date, + amount = value.amount, + currency = value.currency, + transactionHash = value.transactionHash, + type = value.type.toDomain(), + ) + } +} + +private fun TangemPayTxHistoryItem.Status.toDM(): TangemPayTxHistoryItemDM.Status = when (this) { + TangemPayTxHistoryItem.Status.PENDING -> TangemPayTxHistoryItemDM.Status.PENDING + TangemPayTxHistoryItem.Status.RESERVED -> TangemPayTxHistoryItemDM.Status.RESERVED + TangemPayTxHistoryItem.Status.COMPLETED -> TangemPayTxHistoryItemDM.Status.COMPLETED + TangemPayTxHistoryItem.Status.DECLINED -> TangemPayTxHistoryItemDM.Status.DECLINED + TangemPayTxHistoryItem.Status.REVERSED -> TangemPayTxHistoryItemDM.Status.REVERSED + TangemPayTxHistoryItem.Status.UNKNOWN -> TangemPayTxHistoryItemDM.Status.UNKNOWN +} + +private fun TangemPayTxHistoryItemDM.Status.toDomain(): TangemPayTxHistoryItem.Status = when (this) { + TangemPayTxHistoryItemDM.Status.PENDING -> TangemPayTxHistoryItem.Status.PENDING + TangemPayTxHistoryItemDM.Status.RESERVED -> TangemPayTxHistoryItem.Status.RESERVED + TangemPayTxHistoryItemDM.Status.COMPLETED -> TangemPayTxHistoryItem.Status.COMPLETED + TangemPayTxHistoryItemDM.Status.DECLINED -> TangemPayTxHistoryItem.Status.DECLINED + TangemPayTxHistoryItemDM.Status.REVERSED -> TangemPayTxHistoryItem.Status.REVERSED + TangemPayTxHistoryItemDM.Status.UNKNOWN -> TangemPayTxHistoryItem.Status.UNKNOWN +} + +private fun TangemPayTxHistoryItem.Type.toDM(): TangemPayTxHistoryItemDM.Type = when (this) { + TangemPayTxHistoryItem.Type.Deposit -> TangemPayTxHistoryItemDM.Type.Deposit + TangemPayTxHistoryItem.Type.Withdrawal -> TangemPayTxHistoryItemDM.Type.Withdrawal +} + +private fun TangemPayTxHistoryItemDM.Type.toDomain(): TangemPayTxHistoryItem.Type = when (this) { + TangemPayTxHistoryItemDM.Type.Deposit -> TangemPayTxHistoryItem.Type.Deposit + TangemPayTxHistoryItemDM.Type.Withdrawal -> TangemPayTxHistoryItem.Type.Withdrawal +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStoreTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStoreTest.kt new file mode 100644 index 0000000000..1457c5066e --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/DefaultTangemPayTxHistoryItemsStoreTest.kt @@ -0,0 +1,119 @@ +package com.tangem.datasource.local.visa + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDMConverter +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemToDomainConverter +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.test.core.datastore.MockStateDataStore +import kotlinx.coroutines.test.runTest +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.util.Currency + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultTangemPayTxHistoryItemsStoreTest { + + private lateinit var store: DefaultTangemPayTxHistoryItemsStore + + @BeforeEach + fun setup() { + store = DefaultTangemPayTxHistoryItemsStore( + dataStore = MockStateDataStore(default = emptyMap()), + toDMConverter = TangemPayTxHistoryItemToDMConverter(), + toDomainConverter = TangemPayTxHistoryItemToDomainConverter(), + ) + } + + @Test + fun `GIVEN empty store WHEN getSyncOrNull THEN returns null`() = runTest { + val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_1) + + assertThat(result).isNull() + } + + @Test + fun `GIVEN stored page WHEN getSyncOrNull with same key and cursor THEN returns page`() = runTest { + // Arrange + val page = listOf(payment("1"), payment("2")) + store.store(key = WALLET_A, cursor = CURSOR_1, value = page) + + // Act + val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_1) + + // Assert + assertThat(result).isEqualTo(page) + } + + @Test + fun `GIVEN page stored under one cursor WHEN getSyncOrNull with another cursor THEN returns null`() = runTest { + // Arrange + store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("1"))) + + // Act + val result = store.getSyncOrNull(key = WALLET_A, cursor = CURSOR_2) + + // Assert + assertThat(result).isNull() + } + + @Test + fun `GIVEN two cursors stored for one wallet WHEN getSyncOrNull THEN both pages are kept`() = runTest { + // Arrange + val page1 = listOf(payment("1")) + val page2 = listOf(payment("2")) + store.store(key = WALLET_A, cursor = CURSOR_1, value = page1) + store.store(key = WALLET_A, cursor = CURSOR_2, value = page2) + + // Assert + assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isEqualTo(page1) + assertThat(store.getSyncOrNull(WALLET_A, CURSOR_2)).isEqualTo(page2) + } + + @Test + fun `GIVEN entries for two wallets WHEN remove one wallet THEN only that wallet is cleared`() = runTest { + // Arrange + store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("a"))) + store.store(key = WALLET_B, cursor = CURSOR_1, value = listOf(payment("b"))) + + // Act + store.remove(WALLET_A) + + // Assert + assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isNull() + assertThat(store.getSyncOrNull(WALLET_B, CURSOR_1)).isEqualTo(listOf(payment("b"))) + } + + @Test + fun `GIVEN entries for two wallets WHEN remove both in one call THEN both are cleared`() = runTest { + // Arrange + store.store(key = WALLET_A, cursor = CURSOR_1, value = listOf(payment("a"))) + store.store(key = WALLET_B, cursor = CURSOR_1, value = listOf(payment("b"))) + + // Act + store.remove(listOf(WALLET_A, WALLET_B)) + + // Assert + assertThat(store.getSyncOrNull(WALLET_A, CURSOR_1)).isNull() + assertThat(store.getSyncOrNull(WALLET_B, CURSOR_1)).isNull() + } + + private fun payment(id: String) = TangemPayTxHistoryItem.Payment( + id = id, + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("1.00"), + currency = Currency.getInstance("USD"), + transactionHash = null, + ) + + private companion object { + const val WALLET_A = "wallet-a" + const val WALLET_B = "wallet-b" + const val CURSOR_1 = "cursor-1" + const val CURSOR_2 = "cursor-2" + const val DATE_MILLIS = 1_700_000_000_000L + } +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/TangemPayTxHistoryStoreSerializationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/TangemPayTxHistoryStoreSerializationTest.kt new file mode 100644 index 0000000000..e46b7c71be --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/TangemPayTxHistoryStoreSerializationTest.kt @@ -0,0 +1,122 @@ +package com.tangem.datasource.local.visa + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.visa.entity.TangemPayTxHistoryItemDM +import com.tangem.datasource.utils.KotlinxDataStoreSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal +import java.util.Currency + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayTxHistoryStoreSerializationTest { + + private val json = KotlinxDataStoreSerializer.jsonBuilder { classDiscriminator = "__type" } + + private val serializer = MapSerializer( + keySerializer = String.serializer(), + valueSerializer = MapSerializer( + keySerializer = String.serializer(), + valueSerializer = ListSerializer(TangemPayTxHistoryItemDM.serializer()), + ), + ) + + @Test + fun `GIVEN all tx history item types WHEN serialized and deserialized THEN value is preserved`() { + // Arrange + val original: Map>> = mapOf( + "wallet-1" to mapOf( + "initial_cursor_key" to listOf(spend(), payment(), fee(), collateral()), + ), + ) + + // Act + val restored = json.decodeFromString(serializer, json.encodeToString(serializer, original)) + + // Assert + assertThat(restored).isEqualTo(original) + } + + @Test + fun `GIVEN Collateral with type field WHEN serialized and deserialized THEN discriminator does not clash`() { + // Arrange + val original: Map>> = mapOf( + "wallet-1" to mapOf("cursor" to listOf(collateral())), + ) + + // Act + val restored = json.decodeFromString(serializer, json.encodeToString(serializer, original)) + + // Assert + assertThat(restored).isEqualTo(original) + } + + @Test + fun `GIVEN tx history item WHEN serialized THEN uses stable SerialName under non-clashing discriminator`() { + // Arrange + val original: Map>> = mapOf( + "wallet-1" to mapOf("cursor" to listOf(collateral())), + ) + + // Act + val encoded = json.encodeToString(serializer, original) + + // Assert + assertThat(encoded).contains("\"__type\":\"collateral\"") + } + + private fun spend() = TangemPayTxHistoryItemDM.Spend( + id = "spend-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("12.34"), + currency = Currency.getInstance("USD"), + authorizedAmount = BigDecimal("12.34"), + localAmount = BigDecimal("11.00"), + localCurrency = Currency.getInstance("EUR"), + enrichedMerchantName = "Coffee Co", + merchantName = "COFFEE CO", + enrichedMerchantCategory = "Food", + merchantCategoryCode = "5814", + merchantCategory = "restaurants", + status = TangemPayTxHistoryItemDM.Status.COMPLETED, + enrichedMerchantIconUrl = "https://example.com/icon.png", + declinedReason = null, + ) + + private fun payment() = TangemPayTxHistoryItemDM.Payment( + id = "payment-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("100.00"), + currency = Currency.getInstance("USD"), + transactionHash = "0xabc", + ) + + private fun fee() = TangemPayTxHistoryItemDM.Fee( + id = "fee-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("0.50"), + currency = Currency.getInstance("USD"), + description = "network fee", + ) + + private fun collateral() = TangemPayTxHistoryItemDM.Collateral( + id = "collateral-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("250.00"), + currency = Currency.getInstance("USD"), + transactionHash = "0xdef", + type = TangemPayTxHistoryItemDM.Type.Deposit, + ) + + private companion object { + const val DATE_MILLIS = 1_700_000_000_000L + } +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverterTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverterTest.kt new file mode 100644 index 0000000000..7f0125e286 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/visa/entity/TangemPayTxHistoryItemDMConverterTest.kt @@ -0,0 +1,103 @@ +package com.tangem.datasource.local.visa.entity + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.visa.model.TangemPayTxHistoryItem +import com.tangem.test.core.ProvideTestModels +import org.joda.time.DateTime +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import java.math.BigDecimal +import java.util.Currency + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayTxHistoryItemDMConverterTest { + + private val toDMConverter = TangemPayTxHistoryItemToDMConverter() + private val toDomainConverter = TangemPayTxHistoryItemToDomainConverter() + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN every item subtype WHEN converted to DM and back THEN all fields preserved`( + item: TangemPayTxHistoryItem, + ) { + assertRoundTrip(item) + } + + @ParameterizedTest + @EnumSource(TangemPayTxHistoryItem.Status::class) + fun `GIVEN every Spend status WHEN converted to DM and back THEN status preserved`( + status: TangemPayTxHistoryItem.Status, + ) { + assertRoundTrip(spend().copy(status = status)) + } + + @ParameterizedTest + @EnumSource(TangemPayTxHistoryItem.Type::class) + fun `GIVEN every Collateral type WHEN converted to DM and back THEN type preserved`( + type: TangemPayTxHistoryItem.Type, + ) { + assertRoundTrip(collateral().copy(type = type)) + } + + private fun assertRoundTrip(item: TangemPayTxHistoryItem) { + // Act + val restored = toDomainConverter.convert(toDMConverter.convert(item)) + + // Assert + assertThat(restored).isEqualTo(item) + } + + private fun provideTestModels() = listOf(spend(), payment(), fee(), collateral()) + + private fun spend() = TangemPayTxHistoryItem.Spend( + id = "spend-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("12.34"), + currency = Currency.getInstance("USD"), + authorizedAmount = BigDecimal("12.34"), + localAmount = BigDecimal("11.00"), + localCurrency = Currency.getInstance("EUR"), + enrichedMerchantName = "Coffee Co", + merchantName = "COFFEE CO", + enrichedMerchantCategory = "Food", + merchantCategoryCode = "5814", + merchantCategory = "restaurants", + status = TangemPayTxHistoryItem.Status.COMPLETED, + enrichedMerchantIconUrl = "https://example.com/icon.png", + declinedReason = null, + ) + + private fun payment() = TangemPayTxHistoryItem.Payment( + id = "payment-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("100.00"), + currency = Currency.getInstance("USD"), + transactionHash = "0xabc", + ) + + private fun fee() = TangemPayTxHistoryItem.Fee( + id = "fee-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("0.50"), + currency = Currency.getInstance("USD"), + description = "network fee", + ) + + private fun collateral() = TangemPayTxHistoryItem.Collateral( + id = "collateral-1", + jsonRepresentation = "{}", + date = DateTime(DATE_MILLIS), + amount = BigDecimal("250.00"), + currency = Currency.getInstance("USD"), + transactionHash = "0xdef", + type = TangemPayTxHistoryItem.Type.Deposit, + ) + + private companion object { + const val DATE_MILLIS = 1_700_000_000_000L + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleaner.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleaner.kt new file mode 100644 index 0000000000..6fa20862b8 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleaner.kt @@ -0,0 +1,18 @@ +package com.tangem.data.pay + +import com.tangem.data.pay.store.PaymentAccountStatusesStore +import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore +import com.tangem.domain.common.wallets.UserWalletDataCleaner +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject + +internal class TangemPayUserWalletDataCleaner @Inject constructor( + private val paymentAccountStatusesStore: PaymentAccountStatusesStore, + private val txHistoryItemsStore: TangemPayTxHistoryItemsStore, +) : UserWalletDataCleaner { + + override suspend fun clear(userWalletIds: List) { + paymentAccountStatusesStore.remove(userWalletIds) + txHistoryItemsStore.remove(userWalletIds.map { it.stringValue }) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 30297c0d46..a1f8b9698e 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -6,6 +6,7 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.data.pay.DefaultTangemPayEligibilityManager +import com.tangem.data.pay.TangemPayUserWalletDataCleaner import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter import com.tangem.data.pay.entity.DefaultTangemPayCurrencyFactory import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher @@ -21,6 +22,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.domain.common.wallets.UserWalletDataCleaner import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher @@ -41,6 +43,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet import javax.inject.Singleton @Module @@ -117,6 +120,10 @@ internal interface TangemPayDataModule { @Singleton fun bindPaymentAccountStatusFetcher(impl: DefaultPaymentAccountStatusFetcher): PaymentAccountStatusFetcher + @Binds + @IntoSet + fun bindTangemPayUserWalletDataCleaner(impl: TangemPayUserWalletDataCleaner): UserWalletDataCleaner + companion object { @Provides diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 786fc9397c..07fc7b7a5a 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -16,6 +16,7 @@ import com.tangem.datasource.api.pay.models.response.CustomerMeResponse import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus @@ -49,6 +50,7 @@ internal class DefaultOnboardingRepository @Inject constructor( private val cardFrozenStateStore: TangemPayCardFrozenStateStore, private val userWalletsListRepository: UserWalletsListRepository, private val paymentAccountStatusStore: PaymentAccountStatusesStore, + private val txHistoryItemsStore: TangemPayTxHistoryItemsStore, ) : OnboardingRepository { // Save data for a session @@ -271,6 +273,7 @@ internal class DefaultOnboardingRepository @Inject constructor( }.map { val address = requestHelper.getCustomerWalletAddress(userWalletId) tangemPayStorage.clearAll(userWalletId = userWalletId, customerWalletAddress = address) + txHistoryItemsStore.remove(userWalletId.stringValue) setHideMainOnboardingBanner(userWalletId) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt index eab58caa05..90d85dada1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayTxHistoryRepository.kt @@ -18,6 +18,7 @@ import com.tangem.pagination.fetcher.BatchFetcher import com.tangem.pagination.fetcher.CursorBatchFetcher import com.tangem.pagination.toBatchFlow import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import javax.inject.Inject private const val INITIAL_CURSOR = "initial_cursor_key" @@ -76,16 +77,22 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor( cursor: String?, limit: Int, ): List { - cacheRegistry.invokeOnExpire( - key = getCacheKey(userWalletId = userWalletId, cursor = cursor), - skipCache = config.shouldRefresh, - block = { fetch(userWalletId = userWalletId, cursor = cursor, pageSize = limit) }, - ) + val storeKey = userWalletId.stringValue + val storeCursor = cursor ?: INITIAL_CURSOR - return txHistoryItemsStore.getSyncOrNull( - key = userWalletId.stringValue, - cursor = cursor ?: INITIAL_CURSOR, - ).orEmpty() + val fetchResult = runSuspendCatching { + cacheRegistry.invokeOnExpire( + key = getCacheKey(userWalletId = userWalletId, cursor = cursor), + skipCache = config.shouldRefresh, + block = { fetch(userWalletId = userWalletId, cursor = cursor, pageSize = limit) }, + ) + } + val storedPage = txHistoryItemsStore.getSyncOrNull(key = storeKey, cursor = storeCursor) + + return fetchResult.fold( + onSuccess = { storedPage.orEmpty() }, + onFailure = { e -> storedPage?.takeIf { it.isNotEmpty() } ?: throw e }, + ) } private fun getCacheKey(userWalletId: UserWalletId, cursor: String?): String { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index c9ff2fd990..c445c32df4 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -101,6 +101,20 @@ internal class PaymentAccountStatusesStore( return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue) } + suspend fun remove(userWalletId: UserWalletId) { + logger.i("remove($userWalletId)") + remove(userWalletIds = listOf(userWalletId)) + } + + suspend fun remove(userWalletIds: List) { + logger.i("remove($userWalletIds)") + val keys = userWalletIds.map { it.stringValue }.toSet() + coroutineScope { + launch { runtimeStore.update(default = emptyMap()) { it - keys } } + launch { persistenceDataStore.updateData { it - keys } } + } + } + private suspend fun storeInRuntime(userWalletId: UserWalletId, status: AccountStatus.Payment) { runtimeStore.update(default = emptyMap()) { stored -> stored.toMutableMap().apply { diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleanerTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleanerTest.kt new file mode 100644 index 0000000000..9dfaa1e617 --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/TangemPayUserWalletDataCleanerTest.kt @@ -0,0 +1,49 @@ +package com.tangem.data.pay + +import com.tangem.data.pay.store.PaymentAccountStatusesStore +import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayUserWalletDataCleanerTest { + + private val paymentAccountStatusesStore: PaymentAccountStatusesStore = mockk() + private val txHistoryItemsStore: TangemPayTxHistoryItemsStore = mockk() + + private val cleaner = TangemPayUserWalletDataCleaner( + paymentAccountStatusesStore = paymentAccountStatusesStore, + txHistoryItemsStore = txHistoryItemsStore, + ) + + @BeforeEach + fun resetMocks() { + clearMocks(paymentAccountStatusesStore, txHistoryItemsStore) + coEvery { paymentAccountStatusesStore.remove(any>()) } just Runs + coEvery { txHistoryItemsStore.remove(any>()) } just Runs + } + + @Test + fun `GIVEN wallets WHEN clear THEN each store is cleared once with all ids in a single call`() = runTest { + // Act + cleaner.clear(listOf(WALLET_A, WALLET_B)) + + // Assert + coVerify(exactly = 1) { paymentAccountStatusesStore.remove(listOf(WALLET_A, WALLET_B)) } + coVerify(exactly = 1) { txHistoryItemsStore.remove(listOf(WALLET_A.stringValue, WALLET_B.stringValue)) } + } + + private companion object { + val WALLET_A = UserWalletId("011") + val WALLET_B = UserWalletId("022") + } +} \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStoreTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStoreTest.kt new file mode 100644 index 0000000000..d27af0721f --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStoreTest.kt @@ -0,0 +1,89 @@ +package com.tangem.data.pay.store + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter +import com.tangem.datasource.local.datastore.RuntimeSharedStore +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayCurrencyFactory +import com.tangem.test.core.TestAppCoroutineScope +import com.tangem.test.core.datastore.MockStateDataStore +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class PaymentAccountStatusesStoreTest { + + @Test + fun `GIVEN stored status WHEN remove single THEN cleared from both runtime and persistence`() = runTest { + // Arrange + val persistenceDataStore = MockStateDataStore(default = emptyMap()) + val store = createStore(persistenceDataStore) + advanceUntilIdle() + store.storeNotCreated(WALLET_A) + advanceUntilIdle() + // sanity: present in both stores before removal + assertThat(store.getSyncOrNull(WALLET_A)).isNotNull() + assertThat(persistenceDataStore.data.first()).containsKey(WALLET_A.stringValue) + + // Act + store.remove(WALLET_A) + advanceUntilIdle() + + // Assert + assertThat(store.getSyncOrNull(WALLET_A)).isNull() + assertThat(persistenceDataStore.data.first()).doesNotContainKey(WALLET_A.stringValue) + } + + @Test + fun `GIVEN statuses for two wallets WHEN remove list THEN both cleared from both stores`() = runTest { + // Arrange + val persistenceDataStore = MockStateDataStore(default = emptyMap()) + val store = createStore(persistenceDataStore) + advanceUntilIdle() + store.storeNotCreated(WALLET_A) + store.storeNotCreated(WALLET_B) + advanceUntilIdle() + + // Act + store.remove(listOf(WALLET_A, WALLET_B)) + advanceUntilIdle() + + // Assert + assertThat(store.getSyncOrNull(WALLET_A)).isNull() + assertThat(store.getSyncOrNull(WALLET_B)).isNull() + assertThat(persistenceDataStore.data.first()).doesNotContainKey(WALLET_A.stringValue) + assertThat(persistenceDataStore.data.first()).doesNotContainKey(WALLET_B.stringValue) + } + + private fun TestScope.createStore( + persistenceDataStore: MockStateDataStore, + ) = PaymentAccountStatusesStore( + runtimeStore = RuntimeSharedStore(), + persistenceDataStore = persistenceDataStore, + converter = PaymentAccountStatusValueDMConverter(mockk(relaxed = true)), + scope = TestAppCoroutineScope(this), + ) + + private suspend fun PaymentAccountStatusesStore.storeNotCreated(userWalletId: UserWalletId) { + store( + userWalletId = userWalletId, + status = AccountStatus.Payment( + account = Account.Payment(userWalletId), + value = PaymentAccountStatusValue.NotCreated, + ), + ) + } + + private companion object { + val WALLET_A = UserWalletId("011") + val WALLET_B = UserWalletId("022") + } +} \ No newline at end of file diff --git a/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletDataCleaner.kt b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletDataCleaner.kt new file mode 100644 index 0000000000..0ffa1dfcc5 --- /dev/null +++ b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletDataCleaner.kt @@ -0,0 +1,9 @@ +package com.tangem.domain.common.wallets + +import com.tangem.domain.models.wallet.UserWalletId + +/** Removes per-wallet data owned by a feature when its wallets are deleted. */ +interface UserWalletDataCleaner { + + suspend fun clear(userWalletIds: List) +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt index 431d52cb5f..4e44cf1b91 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt @@ -1,10 +1,14 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either +import com.tangem.domain.common.wallets.UserWalletDataCleaner import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.DeleteWalletError import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.launch /** * Use case for deleting user wallet @@ -15,6 +19,8 @@ import com.tangem.utils.logging.TangemLogger */ class DeleteWalletUseCase( private val userWalletsListRepository: UserWalletsListRepository, + private val userWalletDataCleaners: Set, + private val appCoroutineScope: AppCoroutineScope, ) { /** @@ -25,10 +31,22 @@ class DeleteWalletUseCase( * @return [Either] with [com.tangem.domain.common.wallets.error.DeleteWalletError] or [Boolean] which indicates that there are still saved wallets. * */ suspend operator fun invoke(userWalletId: UserWalletId): Either { - return userWalletsListRepository.delete(userWalletIds = listOf(userWalletId)) + val userWalletIds = listOf(userWalletId) + return userWalletsListRepository.delete(userWalletIds = userWalletIds) + .onRight { clearWalletDataInBackground(userWalletIds) } .map { userWalletsListRepository.selectedUserWallet.value != null } .onLeft { - TangemLogger.e("Failed to delete wallet with id ${userWalletId.value}: $it") + TangemLogger.e("Failed to delete wallet with id ${userWalletId.stringValue}: $it") } } + + private fun clearWalletDataInBackground(userWalletIds: List) { + if (userWalletDataCleaners.isEmpty()) return + appCoroutineScope.launch { + userWalletDataCleaners.forEach { cleaner -> + runSuspendCatching { cleaner.clear(userWalletIds) } + .onFailure { TangemLogger.e("Failed to clear data for wallets $userWalletIds", it) } + } + } + } } \ No newline at end of file diff --git a/domain/wallets/src/test/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCaseTest.kt b/domain/wallets/src/test/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCaseTest.kt new file mode 100644 index 0000000000..a1fb44685a --- /dev/null +++ b/domain/wallets/src/test/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCaseTest.kt @@ -0,0 +1,128 @@ +package com.tangem.domain.wallets.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.common.wallets.UserWalletDataCleaner +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.DeleteWalletError +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.TestAppCoroutineScope +import io.mockk.Runs +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DeleteWalletUseCaseTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) + private val firstCleaner: UserWalletDataCleaner = mockk() + private val secondCleaner: UserWalletDataCleaner = mockk() + + @BeforeEach + fun resetMocks() { + clearMocks(userWalletsListRepository, firstCleaner, secondCleaner) + coEvery { userWalletsListRepository.delete(any()) } returns Unit.right() + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(null) + coEvery { firstCleaner.clear(any()) } just Runs + coEvery { secondCleaner.clear(any()) } just Runs + } + + @Test + fun `GIVEN delete succeeds WHEN invoke THEN cleanup runs in background after invoke returns`() = runTest { + // Arrange + val useCase = createUseCase() + + // Act + useCase(WALLET_ID) + + // Assert — cleanup is fire-and-forget: not run yet when invoke returns + coVerify(exactly = 0) { firstCleaner.clear(any()) } + coVerify(exactly = 0) { secondCleaner.clear(any()) } + + // ...and completes for every wallet once the app scope is drained + advanceUntilIdle() + coVerify(exactly = 1) { firstCleaner.clear(listOf(WALLET_ID)) } + coVerify(exactly = 1) { secondCleaner.clear(listOf(WALLET_ID)) } + } + + @Test + fun `GIVEN delete fails WHEN invoke THEN cleaners are not run and error returned`() = runTest { + // Arrange + coEvery { userWalletsListRepository.delete(any()) } returns DeleteWalletError.UnableToDelete.left() + val useCase = createUseCase() + + // Act + val result = useCase(WALLET_ID) + advanceUntilIdle() + + // Assert + assertThat(result).isEqualTo(DeleteWalletError.UnableToDelete.left()) + coVerify(exactly = 0) { firstCleaner.clear(any()) } + coVerify(exactly = 0) { secondCleaner.clear(any()) } + } + + @Test + fun `GIVEN a wallet still selected WHEN invoke THEN returns right true`() = runTest { + // Arrange + every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(wallet) + val useCase = createUseCase() + + // Act + val result = useCase(WALLET_ID) + + // Assert + assertThat(result).isEqualTo(true.right()) + } + + @Test + fun `GIVEN no wallet selected WHEN invoke THEN returns right false`() = runTest { + // Arrange + val useCase = createUseCase() + + // Act + val result = useCase(WALLET_ID) + + // Assert + assertThat(result).isEqualTo(false.right()) + } + + @Test + fun `GIVEN one cleaner throws WHEN invoke THEN other cleaners still run and result is right`() = runTest { + // Arrange + coEvery { firstCleaner.clear(any()) } throws IllegalStateException("boom") + val useCase = createUseCase() + + // Act + val result = useCase(WALLET_ID) + advanceUntilIdle() + + // Assert + assertThat(result).isEqualTo(false.right()) + coVerify(exactly = 1) { secondCleaner.clear(listOf(WALLET_ID)) } + } + + private fun TestScope.createUseCase() = DeleteWalletUseCase( + userWalletsListRepository = userWalletsListRepository, + userWalletDataCleaners = setOf(firstCleaner, secondCleaner), + appCoroutineScope = TestAppCoroutineScope(this), + ) + + private companion object { + val WALLET_ID = UserWalletId("0011") + val wallet: UserWallet = MockUserWalletFactory.create() + } +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt index 0a4666a24b..1102b254e5 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt @@ -16,6 +16,7 @@ import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS import com.tangem.domain.wallets.hot.HotWalletPasswordRequester +import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM @@ -37,6 +38,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, private val userWalletsListRepository: UserWalletsListRepository, + private val deleteWalletUseCase: DeleteWalletUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, @@ -224,7 +226,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( startAssetsDiscoveryUseCase.cancel(userWallet.walletId) } - userWalletsListRepository.delete(listOf(userWallet.walletId)) + deleteWalletUseCase(userWallet.walletId) dismiss() } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index b9ef7fc608..53e147e2b9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -93,6 +93,7 @@ internal sealed class TangemPayDetailsBalanceBlockState { override val cardsBlockState: CardsBlockState?, val fiatBalance: TextReference, val isBalanceFlickering: Boolean, + val isMuted: Boolean = false, ) : TangemPayDetailsBalanceBlockState() data class Error( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index b6c400b7bc..96b6b103de 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -23,6 +23,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.TangemPayCustomerTariffPlan @@ -139,17 +140,25 @@ internal class TangemPayDetailsModel @Inject constructor( .onEach { state -> when (state) { is PaymentAccountStatusValue.Deactivated -> { + val balanceTransformer = DetailsBalanceTransformer( + fiatBalance = state.balance.fiatBalance, + isMuted = state.source != StatusSource.ACTUAL, + ) uiState.update { - stateFactory.getDeactivatedState( - hasWithdrawableBalance = state.balance.hasWithdrawableAmount, + balanceTransformer.transform( + stateFactory.getDeactivatedState( + hasWithdrawableBalance = state.balance.hasWithdrawableAmount, + ), ) } - uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) } is PaymentAccountStatusValue.Loaded -> { fetchAddToWalletBanner() - uiState.update { stateFactory.getLoadedState(state) } - uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) + val balanceTransformer = DetailsBalanceTransformer( + fiatBalance = state.balance.fiatBalance, + isMuted = !state.isFresh, + ) + uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) } state.cards.firstOrNull()?.let { card -> subscribeToCardFrozenState(card.id) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index 5cbd4bdb05..97b8c574ec 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -12,6 +12,7 @@ import java.util.Currency internal class DetailsBalanceTransformer( private val fiatBalance: PaymentAccountStatusValue.FiatBalance, + private val isMuted: Boolean = false, ) : Transformer { override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { @@ -20,6 +21,7 @@ internal class DetailsBalanceTransformer( fiatBalance = getFiatBalanceText(fiatBalance), actionButtons = prevState.balanceBlockState.actionButtons, cardsBlockState = prevState.balanceBlockState.cardsBlockState, + isMuted = isMuted, ) return prevState.copy(balanceBlockState = balance) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt index ef167d1fd5..aab5f9b53a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt @@ -355,21 +355,28 @@ private fun BalanceBlock( style = TextShimmerStyle.HEADING_MEDIUM, radius = TangemTheme.dimens2.x25, ) - is TangemPayDetailsBalanceBlockState.Content -> Text( - modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), - text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), - style = TangemTheme.typography3.display.medium.applyBladeBrush( - isEnabled = animatedState.isBalanceFlickering, - textColor = TangemTheme.colors3.text.primary, - ), - color = TangemTheme.colors3.text.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - autoSize = TextAutoSize.StepBased( - minFontSize = TangemTheme.typography3.heading.medium.fontSize, - maxFontSize = TangemTheme.typography3.display.medium.fontSize, - ), - ) + is TangemPayDetailsBalanceBlockState.Content -> { + val balanceColor = if (animatedState.isMuted) { + TangemTheme.colors3.text.secondary + } else { + TangemTheme.colors3.text.primary + } + Text( + modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), + text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + style = TangemTheme.typography3.display.medium.applyBladeBrush( + isEnabled = animatedState.isBalanceFlickering, + textColor = balanceColor, + ), + color = balanceColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.heading.medium.fontSize, + maxFontSize = TangemTheme.typography3.display.medium.fontSize, + ), + ) + } is TangemPayDetailsBalanceBlockState.Error -> Text( modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), text = DASH_SIGN.orMaskWithStars(isBalanceHidden), diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt index c511c80187..eb9f5dd782 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt @@ -12,6 +12,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.features.tangempay.addFundsButton import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState import com.tangem.features.tangempay.tangemPayCard import com.tangem.features.tangempay.withdrawButton import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider @@ -57,15 +58,36 @@ internal class TangemPayDetailsModelTest { model.onDestroy() } + @ParameterizedTest + @MethodSource("provideMutedCases") + fun `GIVEN status source WHEN status loaded THEN balance is muted only when cached`(case: MutedCase) = runTest { + // Arrange + Act + val model = createModel( + testScope = this, + statusSource = case.statusSource, + frozenState = TangemPayCardFrozenState.Unfrozen, + availableForWithdrawal = BigDecimal.ZERO, + accountError = case.accountError, + ) + advanceUntilIdle() + + // Assert + val balanceState = model.uiState.value.balanceBlockState + assertThat(balanceState).isInstanceOf(TangemPayDetailsBalanceBlockState.Content::class.java) + assertThat((balanceState as TangemPayDetailsBalanceBlockState.Content).isMuted).isEqualTo(case.expectedMuted) + model.onDestroy() + } + private fun createModel( testScope: TestScope, statusSource: StatusSource, frozenState: TangemPayCardFrozenState, availableForWithdrawal: BigDecimal, + accountError: PaymentAccountStatusValue.Error? = null, ): TangemPayDetailsModel { val loaded: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { every { source } returns statusSource - every { error } returns null + every { error } returns accountError every { cards } returns listOf(tangemPayCard()) every { balance } returns PaymentAccountStatusValue.Balance( fiatBalance = PaymentAccountStatusValue.FiatBalance( @@ -135,6 +157,12 @@ internal class TangemPayDetailsModelTest { val expectedWithdrawEnabled: Boolean, ) + internal data class MutedCase( + val statusSource: StatusSource, + val expectedMuted: Boolean, + val accountError: PaymentAccountStatusValue.Error? = null, + ) + private companion object { @JvmStatic fun provideFreezeCases() = listOf( @@ -167,5 +195,18 @@ internal class TangemPayDetailsModelTest { expectedWithdrawEnabled = false, ), ) + + @JvmStatic + fun provideMutedCases() = listOf( + MutedCase(statusSource = StatusSource.ACTUAL, expectedMuted = false), + MutedCase(statusSource = StatusSource.CACHE, expectedMuted = true), + MutedCase(statusSource = StatusSource.ONLY_CACHE, expectedMuted = true), + // ACTUAL but errored is still not fresh -> muted (guards !isFresh, not just source != ACTUAL) + MutedCase( + statusSource = StatusSource.ACTUAL, + accountError = PaymentAccountStatusValue.Error.Unavailable, + expectedMuted = true, + ), + ) } } \ No newline at end of file