Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-01 11:50:47 +03:00
parent 1edf995ab0
commit 2dadb60975
26 changed files with 1110 additions and 48 deletions

View file

@ -2,6 +2,7 @@ package com.tangem.tap.di.domain
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.repository.AccountsCRUDRepository 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.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.transaction.WalletAddressServiceRepository 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.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.operations.attestation.CardArtworksProvider
import com.tangem.tap.domain.DefaultUserWalletSelectedHandler import com.tangem.tap.domain.DefaultUserWalletSelectedHandler
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -188,8 +190,16 @@ internal object WalletsDomainModule {
@Provides @Provides
@Singleton @Singleton
fun providesDeleteWalletUseCase(userWalletsListRepository: UserWalletsListRepository): DeleteWalletUseCase { fun providesDeleteWalletUseCase(
return DeleteWalletUseCase(userWalletsListRepository = userWalletsListRepository) userWalletsListRepository: UserWalletsListRepository,
userWalletDataCleaners: Set<@JvmSuppressWildcards UserWalletDataCleaner>,
appCoroutineScope: AppCoroutineScope,
): DeleteWalletUseCase {
return DeleteWalletUseCase(
userWalletsListRepository = userWalletsListRepository,
userWalletDataCleaners = userWalletDataCleaners,
appCoroutineScope = appCoroutineScope,
)
} }
@Provides @Provides

View file

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

View file

@ -1,14 +1,27 @@
package com.tangem.datasource.di 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.datastore.RuntimeDataStore
import com.tangem.datasource.local.txhistory.DefaultTxHistoryItemsStore import com.tangem.datasource.local.txhistory.DefaultTxHistoryItemsStore
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.visa.DefaultTangemPayTxHistoryItemsStore import com.tangem.datasource.local.visa.DefaultTangemPayTxHistoryItemsStore
import com.tangem.datasource.local.visa.TangemPayTxHistoryItemsStore 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.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -25,9 +38,34 @@ internal object TxHistoryItemsStoreModule {
@Provides @Provides
@Singleton @Singleton
fun provideTangemPayTxHistoryItemsStore(): TangemPayTxHistoryItemsStore { fun provideTangemPayTxHistoryItemsStore(
@ApplicationContext context: Context,
appScope: AppCoroutineScope,
toDMConverter: TangemPayTxHistoryItemToDMConverter,
toDomainConverter: TangemPayTxHistoryItemToDomainConverter,
): TangemPayTxHistoryItemsStore {
return DefaultTangemPayTxHistoryItemsStore( 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,
) )
} }
} }

View file

@ -1,23 +1,37 @@
package com.tangem.datasource.local.visa package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore import androidx.datastore.core.DataStore
import com.tangem.datasource.local.datastore.core.StringKeyDataStoreDecorator 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 com.tangem.domain.visa.model.TangemPayTxHistoryItem
import kotlinx.coroutines.flow.first
internal typealias StoredTangemPayTxHistory = Map<String, Map<String, List<TangemPayTxHistoryItemDM>>>
internal class DefaultTangemPayTxHistoryItemsStore( internal class DefaultTangemPayTxHistoryItemsStore(
dataStore: StringKeyDataStore<Map<String, List<TangemPayTxHistoryItem>>>, private val dataStore: DataStore<StoredTangemPayTxHistory>,
) : TangemPayTxHistoryItemsStore, private val toDMConverter: TangemPayTxHistoryItemToDMConverter,
StringKeyDataStoreDecorator<String, Map<String, List<TangemPayTxHistoryItem>>>(dataStore) { private val toDomainConverter: TangemPayTxHistoryItemToDomainConverter,
override fun provideStringKey(key: String): String = key ) : TangemPayTxHistoryItemsStore {
override suspend fun getSyncOrNull(key: String, cursor: String): List<TangemPayTxHistoryItem>? { override suspend fun getSyncOrNull(key: String, cursor: String): List<TangemPayTxHistoryItem>? {
val storedValue = getSyncOrNull(key) return dataStore.data.first()[key]?.get(cursor)?.let { toDomainConverter.convertList(it) }
return storedValue?.get(cursor)
} }
override suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>) { override suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>) {
val oldValue = getSyncOrNull(key).orEmpty() val page = toDMConverter.convertList(value)
val newValue = oldValue.toMutableMap().apply { put(cursor, value) } dataStore.updateData { stored ->
store(key, newValue) 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<String>) {
dataStore.updateData { stored -> stored - keys.toSet() }
} }
} }

View file

@ -8,5 +8,7 @@ interface TangemPayTxHistoryItemsStore {
suspend fun remove(key: String) suspend fun remove(key: String)
suspend fun remove(keys: List<String>)
suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>) suspend fun store(key: String, cursor: String, value: List<TangemPayTxHistoryItem>)
} }

View file

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

View file

@ -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<TangemPayTxHistoryItem, TangemPayTxHistoryItemDM> {
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<TangemPayTxHistoryItemDM, TangemPayTxHistoryItem> {
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
}

View file

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

View file

@ -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<String, Map<String, List<TangemPayTxHistoryItemDM>>> = 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<String, Map<String, List<TangemPayTxHistoryItemDM>>> = 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<String, Map<String, List<TangemPayTxHistoryItemDM>>> = 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
}
}

View file

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

View file

@ -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<UserWalletId>) {
paymentAccountStatusesStore.remove(userWalletIds)
txHistoryItemsStore.remove(userWalletIds.map { it.stringValue })
}
}

View file

@ -6,6 +6,7 @@ import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.tangem.data.pay.DefaultTangemPayEligibilityManager import com.tangem.data.pay.DefaultTangemPayEligibilityManager
import com.tangem.data.pay.TangemPayUserWalletDataCleaner
import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter
import com.tangem.data.pay.entity.DefaultTangemPayCurrencyFactory import com.tangem.data.pay.entity.DefaultTangemPayCurrencyFactory
import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher 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.local.visa.entity.PaymentAccountStatusValueDM
import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.pay.TangemPayCurrencyFactory
import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
@ -41,6 +43,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -117,6 +120,10 @@ internal interface TangemPayDataModule {
@Singleton @Singleton
fun bindPaymentAccountStatusFetcher(impl: DefaultPaymentAccountStatusFetcher): PaymentAccountStatusFetcher fun bindPaymentAccountStatusFetcher(impl: DefaultPaymentAccountStatusFetcher): PaymentAccountStatusFetcher
@Binds
@IntoSet
fun bindTangemPayUserWalletDataCleaner(impl: TangemPayUserWalletDataCleaner): UserWalletDataCleaner
companion object { companion object {
@Provides @Provides

View file

@ -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.api.pay.models.response.OrderResponse
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayStorage 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.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
@ -49,6 +50,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
private val cardFrozenStateStore: TangemPayCardFrozenStateStore, private val cardFrozenStateStore: TangemPayCardFrozenStateStore,
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val paymentAccountStatusStore: PaymentAccountStatusesStore, private val paymentAccountStatusStore: PaymentAccountStatusesStore,
private val txHistoryItemsStore: TangemPayTxHistoryItemsStore,
) : OnboardingRepository { ) : OnboardingRepository {
// Save data for a session // Save data for a session
@ -271,6 +273,7 @@ internal class DefaultOnboardingRepository @Inject constructor(
}.map { }.map {
val address = requestHelper.getCustomerWalletAddress(userWalletId) val address = requestHelper.getCustomerWalletAddress(userWalletId)
tangemPayStorage.clearAll(userWalletId = userWalletId, customerWalletAddress = address) tangemPayStorage.clearAll(userWalletId = userWalletId, customerWalletAddress = address)
txHistoryItemsStore.remove(userWalletId.stringValue)
setHideMainOnboardingBanner(userWalletId) setHideMainOnboardingBanner(userWalletId)
} }
} }

View file

@ -18,6 +18,7 @@ import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.fetcher.CursorBatchFetcher import com.tangem.pagination.fetcher.CursorBatchFetcher
import com.tangem.pagination.toBatchFlow import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject import javax.inject.Inject
private const val INITIAL_CURSOR = "initial_cursor_key" private const val INITIAL_CURSOR = "initial_cursor_key"
@ -76,16 +77,22 @@ internal class DefaultTangemPayTxHistoryRepository @Inject constructor(
cursor: String?, cursor: String?,
limit: Int, limit: Int,
): List<TangemPayTxHistoryItem> { ): List<TangemPayTxHistoryItem> {
cacheRegistry.invokeOnExpire( val storeKey = userWalletId.stringValue
key = getCacheKey(userWalletId = userWalletId, cursor = cursor), val storeCursor = cursor ?: INITIAL_CURSOR
skipCache = config.shouldRefresh,
block = { fetch(userWalletId = userWalletId, cursor = cursor, pageSize = limit) },
)
return txHistoryItemsStore.getSyncOrNull( val fetchResult = runSuspendCatching {
key = userWalletId.stringValue, cacheRegistry.invokeOnExpire(
cursor = cursor ?: INITIAL_CURSOR, key = getCacheKey(userWalletId = userWalletId, cursor = cursor),
).orEmpty() 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 { private fun getCacheKey(userWalletId: UserWalletId, cursor: String?): String {

View file

@ -101,6 +101,20 @@ internal class PaymentAccountStatusesStore(
return runtimeStore.getSyncOrDefault(emptyMap()).containsKey(userWalletId.stringValue) 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<UserWalletId>) {
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) { private suspend fun storeInRuntime(userWalletId: UserWalletId, status: AccountStatus.Payment) {
runtimeStore.update(default = emptyMap()) { stored -> runtimeStore.update(default = emptyMap()) { stored ->
stored.toMutableMap().apply { stored.toMutableMap().apply {

View file

@ -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<List<UserWalletId>>()) } just Runs
coEvery { txHistoryItemsStore.remove(any<List<String>>()) } 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")
}
}

View file

@ -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<WalletIdWithPaymentStatusDM>(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<WalletIdWithPaymentStatusDM>(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<WalletIdWithPaymentStatusDM>,
) = PaymentAccountStatusesStore(
runtimeStore = RuntimeSharedStore<WalletIdWithPaymentStatus>(),
persistenceDataStore = persistenceDataStore,
converter = PaymentAccountStatusValueDMConverter(mockk<TangemPayCurrencyFactory>(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")
}
}

View file

@ -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<UserWalletId>)
}

View file

@ -1,10 +1,14 @@
package com.tangem.domain.wallets.usecase package com.tangem.domain.wallets.usecase
import arrow.core.Either import arrow.core.Either
import com.tangem.domain.common.wallets.UserWalletDataCleaner
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.error.DeleteWalletError import com.tangem.domain.common.wallets.error.DeleteWalletError
import com.tangem.domain.models.wallet.UserWalletId 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 com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
/** /**
* Use case for deleting user wallet * Use case for deleting user wallet
@ -15,6 +19,8 @@ import com.tangem.utils.logging.TangemLogger
*/ */
class DeleteWalletUseCase( class DeleteWalletUseCase(
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val userWalletDataCleaners: Set<UserWalletDataCleaner>,
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. * @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<DeleteWalletError, Boolean> { suspend operator fun invoke(userWalletId: UserWalletId): Either<DeleteWalletError, Boolean> {
return userWalletsListRepository.delete(userWalletIds = listOf(userWalletId)) val userWalletIds = listOf(userWalletId)
return userWalletsListRepository.delete(userWalletIds = userWalletIds)
.onRight { clearWalletDataInBackground(userWalletIds) }
.map { userWalletsListRepository.selectedUserWallet.value != null } .map { userWalletsListRepository.selectedUserWallet.value != null }
.onLeft { .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<UserWalletId>) {
if (userWalletDataCleaners.isEmpty()) return
appCoroutineScope.launch {
userWalletDataCleaners.forEach { cleaner ->
runSuspendCatching { cleaner.clear(userWalletIds) }
.onFailure { TangemLogger.e("Failed to clear data for wallets $userWalletIds", it) }
}
}
}
} }

View file

@ -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<UserWallet?>(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()
}
}

View file

@ -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.Attempts
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_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.hot.HotWalletPasswordRequester
import com.tangem.domain.wallets.usecase.DeleteWalletUseCase
import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.hotwallet.HotWalletFeatureToggles
import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH import com.tangem.features.hotwallet.accesscode.ACCESS_CODE_LENGTH
import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM import com.tangem.features.hotwallet.accesscoderequest.entity.HotAccessCodeRequestUM
@ -37,6 +38,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository, private val hotAccessCodeAttemptsRepository: HotWalletAccessCodeAttemptsRepository,
private val userWalletsListRepository: UserWalletsListRepository, private val userWalletsListRepository: UserWalletsListRepository,
private val deleteWalletUseCase: DeleteWalletUseCase,
private val canUseBiometryUseCase: CanUseBiometryUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase,
@ -224,7 +226,7 @@ internal class HotAccessCodeRequestModel @Inject constructor(
startAssetsDiscoveryUseCase.cancel(userWallet.walletId) startAssetsDiscoveryUseCase.cancel(userWallet.walletId)
} }
userWalletsListRepository.delete(listOf(userWallet.walletId)) deleteWalletUseCase(userWallet.walletId)
dismiss() dismiss()
} }

View file

@ -93,6 +93,7 @@ internal sealed class TangemPayDetailsBalanceBlockState {
override val cardsBlockState: CardsBlockState?, override val cardsBlockState: CardsBlockState?,
val fiatBalance: TextReference, val fiatBalance: TextReference,
val isBalanceFlickering: Boolean, val isBalanceFlickering: Boolean,
val isMuted: Boolean = false,
) : TangemPayDetailsBalanceBlockState() ) : TangemPayDetailsBalanceBlockState()
data class Error( data class Error(

View file

@ -23,6 +23,7 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.feedback.models.WalletMetaInfo
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.account.TangemPayCustomerTariffPlan import com.tangem.domain.models.account.TangemPayCustomerTariffPlan
@ -139,17 +140,25 @@ internal class TangemPayDetailsModel @Inject constructor(
.onEach { state -> .onEach { state ->
when (state) { when (state) {
is PaymentAccountStatusValue.Deactivated -> { is PaymentAccountStatusValue.Deactivated -> {
val balanceTransformer = DetailsBalanceTransformer(
fiatBalance = state.balance.fiatBalance,
isMuted = state.source != StatusSource.ACTUAL,
)
uiState.update { uiState.update {
stateFactory.getDeactivatedState( balanceTransformer.transform(
hasWithdrawableBalance = state.balance.hasWithdrawableAmount, stateFactory.getDeactivatedState(
hasWithdrawableBalance = state.balance.hasWithdrawableAmount,
),
) )
} }
uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance))
} }
is PaymentAccountStatusValue.Loaded -> { is PaymentAccountStatusValue.Loaded -> {
fetchAddToWalletBanner() fetchAddToWalletBanner()
uiState.update { stateFactory.getLoadedState(state) } val balanceTransformer = DetailsBalanceTransformer(
uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) fiatBalance = state.balance.fiatBalance,
isMuted = !state.isFresh,
)
uiState.update { balanceTransformer.transform(stateFactory.getLoadedState(state)) }
state.cards.firstOrNull()?.let { card -> state.cards.firstOrNull()?.let { card ->
subscribeToCardFrozenState(card.id) subscribeToCardFrozenState(card.id)
} }

View file

@ -12,6 +12,7 @@ import java.util.Currency
internal class DetailsBalanceTransformer( internal class DetailsBalanceTransformer(
private val fiatBalance: PaymentAccountStatusValue.FiatBalance, private val fiatBalance: PaymentAccountStatusValue.FiatBalance,
private val isMuted: Boolean = false,
) : Transformer<TangemPayDetailsUM> { ) : Transformer<TangemPayDetailsUM> {
override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM {
@ -20,6 +21,7 @@ internal class DetailsBalanceTransformer(
fiatBalance = getFiatBalanceText(fiatBalance), fiatBalance = getFiatBalanceText(fiatBalance),
actionButtons = prevState.balanceBlockState.actionButtons, actionButtons = prevState.balanceBlockState.actionButtons,
cardsBlockState = prevState.balanceBlockState.cardsBlockState, cardsBlockState = prevState.balanceBlockState.cardsBlockState,
isMuted = isMuted,
) )
return prevState.copy(balanceBlockState = balance) return prevState.copy(balanceBlockState = balance)
} }

View file

@ -355,21 +355,28 @@ private fun BalanceBlock(
style = TextShimmerStyle.HEADING_MEDIUM, style = TextShimmerStyle.HEADING_MEDIUM,
radius = TangemTheme.dimens2.x25, radius = TangemTheme.dimens2.x25,
) )
is TangemPayDetailsBalanceBlockState.Content -> Text( is TangemPayDetailsBalanceBlockState.Content -> {
modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), val balanceColor = if (animatedState.isMuted) {
text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), TangemTheme.colors3.text.secondary
style = TangemTheme.typography3.display.medium.applyBladeBrush( } else {
isEnabled = animatedState.isBalanceFlickering, TangemTheme.colors3.text.primary
textColor = TangemTheme.colors3.text.primary, }
), Text(
color = TangemTheme.colors3.text.primary, modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE),
maxLines = 1, text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
overflow = TextOverflow.Ellipsis, style = TangemTheme.typography3.display.medium.applyBladeBrush(
autoSize = TextAutoSize.StepBased( isEnabled = animatedState.isBalanceFlickering,
minFontSize = TangemTheme.typography3.heading.medium.fontSize, textColor = balanceColor,
maxFontSize = TangemTheme.typography3.display.medium.fontSize, ),
), 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( is TangemPayDetailsBalanceBlockState.Error -> Text(
modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE),
text = DASH_SIGN.orMaskWithStars(isBalanceHidden), text = DASH_SIGN.orMaskWithStars(isBalanceHidden),

View file

@ -12,6 +12,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import com.tangem.features.tangempay.addFundsButton import com.tangem.features.tangempay.addFundsButton
import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent 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.tangemPayCard
import com.tangem.features.tangempay.withdrawButton import com.tangem.features.tangempay.withdrawButton
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
@ -57,15 +58,36 @@ internal class TangemPayDetailsModelTest {
model.onDestroy() 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( private fun createModel(
testScope: TestScope, testScope: TestScope,
statusSource: StatusSource, statusSource: StatusSource,
frozenState: TangemPayCardFrozenState, frozenState: TangemPayCardFrozenState,
availableForWithdrawal: BigDecimal, availableForWithdrawal: BigDecimal,
accountError: PaymentAccountStatusValue.Error? = null,
): TangemPayDetailsModel { ): TangemPayDetailsModel {
val loaded: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { val loaded: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) {
every { source } returns statusSource every { source } returns statusSource
every { error } returns null every { error } returns accountError
every { cards } returns listOf(tangemPayCard()) every { cards } returns listOf(tangemPayCard())
every { balance } returns PaymentAccountStatusValue.Balance( every { balance } returns PaymentAccountStatusValue.Balance(
fiatBalance = PaymentAccountStatusValue.FiatBalance( fiatBalance = PaymentAccountStatusValue.FiatBalance(
@ -135,6 +157,12 @@ internal class TangemPayDetailsModelTest {
val expectedWithdrawEnabled: Boolean, val expectedWithdrawEnabled: Boolean,
) )
internal data class MutedCase(
val statusSource: StatusSource,
val expectedMuted: Boolean,
val accountError: PaymentAccountStatusValue.Error? = null,
)
private companion object { private companion object {
@JvmStatic @JvmStatic
fun provideFreezeCases() = listOf( fun provideFreezeCases() = listOf(
@ -167,5 +195,18 @@ internal class TangemPayDetailsModelTest {
expectedWithdrawEnabled = false, 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,
),
)
} }
} }