Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-16 16:26:46 +05:00
parent 9424928e3e
commit 2ed66a5049
7 changed files with 264 additions and 5 deletions

View file

@ -78,6 +78,23 @@ internal class DefaultAssetsDiscoveryRepository(
assetsDiscoveryStore.clear() assetsDiscoveryStore.clear()
} }
override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
if (currencies.isEmpty()) return
val appliedIds = currencies.mapTo(hashSetOf(), CryptoCurrency::id)
val userWallet = userWalletsListRepository.getSyncStrict(userWalletId)
val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId)
assetsDiscoveryStore.removeMatching { token ->
val currency = responseCryptoCurrenciesFactory.createCurrency(
responseToken = token,
userWallet = userWallet,
accountIndex = DerivationIndex.Main,
)
currency != null && currency.id in appliedIds
}
}
override suspend fun clearPendingFlag(userWalletId: UserWalletId) { override suspend fun clearPendingFlag(userWalletId: UserWalletId) {
setPendingFlag(userWalletId, value = false) setPendingFlag(userWalletId, value = false)
} }

View file

@ -8,5 +8,7 @@ interface AssetsDiscoveryStore {
suspend fun append(tokens: List<UserTokensResponse.Token>) suspend fun append(tokens: List<UserTokensResponse.Token>)
suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean)
suspend fun clear() suspend fun clear()
} }

View file

@ -18,6 +18,12 @@ internal class DefaultAssetsDiscoveryStore(
} }
} }
override suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean) {
persistenceStore.updateData { existing ->
existing.filterNot(predicate)
}
}
override suspend fun clear() { override suspend fun clear() {
persistenceStore.updateData { emptyList() } persistenceStore.updateData { emptyList() }
} }

View file

@ -8,6 +8,10 @@ android {
namespace = "com.tangem.domain.assetsdiscovery" namespace = "com.tangem.domain.assetsdiscovery"
} }
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies { dependencies {
api(projects.domain.core) api(projects.domain.core)
implementation(projects.domain.models) implementation(projects.domain.models)
@ -19,4 +23,10 @@ dependencies {
implementation(deps.kotlin.coroutines) implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core) implementation(deps.arrow.core)
// region Tests
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// endregion
} }

View file

@ -22,4 +22,6 @@ interface AssetsDiscoveryRepository {
suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency>
suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) suspend fun clearDiscoveredTokens(userWalletId: UserWalletId)
suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>)
} }

View file

@ -28,9 +28,10 @@ class StartAssetsDiscoveryUseCase(
try { try {
analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted()) analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted())
assetsDiscoveryRepository.runDiscovery(userWalletId) assetsDiscoveryRepository.runDiscovery(userWalletId)
applyDiscoveredTokens(userWalletId) if (applyDiscoveredTokens(userWalletId)) {
assetsDiscoveryRepository.completeDiscovery(userWalletId) assetsDiscoveryRepository.completeDiscovery(userWalletId)
analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted()) analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted())
}
} catch (e: Exception) { } catch (e: Exception) {
TangemLogger.e("Token sync failed for wallet: $userWalletId", e) TangemLogger.e("Token sync failed for wallet: $userWalletId", e)
} finally { } finally {
@ -51,7 +52,13 @@ class StartAssetsDiscoveryUseCase(
val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds() val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds()
for (walletId in pendingIds) { for (walletId in pendingIds) {
val isApplied = applyDiscoveredTokens(walletId) val isApplied = applyDiscoveredTokens(walletId)
if (isApplied) { // Clear the pending flag only when discovery is not actively running for this wallet.
// While runDiscovery is in progress it keeps appending tokens after our snapshot, so the
// store may still hold un-applied tokens even though applyDiscoveredTokens returned true.
// Clearing the flag now would strand those tokens if the app is killed before discovery
// finishes; in that case invoke()/completeDiscovery owns clearing the flag once everything
// has been applied.
if (isApplied && !activeSyncJobs.containsKey(walletId)) {
assetsDiscoveryRepository.clearPendingFlag(walletId) assetsDiscoveryRepository.clearPendingFlag(walletId)
} }
} }
@ -72,7 +79,7 @@ class StartAssetsDiscoveryUseCase(
add = currencies, add = currencies,
).fold( ).fold(
ifRight = { ifRight = {
assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId) assetsDiscoveryRepository.removeAppliedCurrencies(userWalletId, currencies)
true true
}, },
ifLeft = { error -> ifLeft = { error ->

View file

@ -0,0 +1,215 @@
package com.tangem.domain.assetsdiscovery.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress
import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
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 StartAssetsDiscoveryUseCaseTest {
private val userWalletId = UserWalletId("011")
private val currencyFactory = MockCryptoCurrencyFactory()
private val appliedCurrency = currencyFactory.ethereum
private val lateDiscoveredCurrency = currencyFactory.createCoin(Blockchain.Polygon)
private val manageCryptoCurrenciesUseCase = mockk<ManageCryptoCurrenciesUseCase>()
private val analyticsEventHandler = mockk<AnalyticsEventHandler>(relaxed = true)
@BeforeEach
fun resetMocks() {
clearMocks(manageCryptoCurrenciesUseCase, analyticsEventHandler)
}
/**
* Reproduces AND race: `WalletModel.init` triggers [StartAssetsDiscoveryUseCase.applyPendingAssetsDiscovery]
* while [AssetsDiscoveryRepository.runDiscovery] is still appending tokens. A network discovered during the
* (slow) apply window must NOT be wiped by the apply's store cleanup.
*/
@Test
fun `apply keeps tokens discovered concurrently while applying the snapshot`() = runTest {
val repository = FakeAssetsDiscoveryRepository(
initialCurrencies = listOf(appliedCurrency),
pendingWalletIds = listOf(userWalletId),
)
// Simulate an in-flight discovery batch appending a new network during the apply call.
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } coAnswers {
repository.store.add(lateDiscoveredCurrency)
Unit.right()
}
val useCase = createUseCase(repository)
useCase.applyPendingAssetsDiscovery()
advanceUntilIdle()
assertThat(repository.store.map { it.id }).contains(lateDiscoveredCurrency.id)
assertThat(repository.store.map { it.id }).doesNotContain(appliedCurrency.id)
}
@Test
fun `apply removes every applied currency when nothing is discovered concurrently`() = runTest {
val repository = FakeAssetsDiscoveryRepository(
initialCurrencies = listOf(appliedCurrency, lateDiscoveredCurrency),
pendingWalletIds = listOf(userWalletId),
)
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right()
val useCase = createUseCase(repository)
useCase.applyPendingAssetsDiscovery()
advanceUntilIdle()
assertThat(repository.store).isEmpty()
coVerify(exactly = 1) { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) }
assertThat(repository.clearedPendingFlagFor).containsExactly(userWalletId)
}
@Test
fun `apply retains tokens and keeps pending flag when applying fails`() = runTest {
val repository = FakeAssetsDiscoveryRepository(
initialCurrencies = listOf(appliedCurrency),
pendingWalletIds = listOf(userWalletId),
)
coEvery {
manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any())
} returns IllegalStateException("apply failed").left()
val useCase = createUseCase(repository)
useCase.applyPendingAssetsDiscovery()
advanceUntilIdle()
assertThat(repository.store.map { it.id }).containsExactly(appliedCurrency.id)
assertThat(repository.clearedPendingFlagFor).isEmpty()
}
/**
* Guards against premature pending-flag clearing: while `runDiscovery` is still in flight (the wallet
* has an active sync job), `applyPendingAssetsDiscovery` must NOT clear the flag, otherwise tokens
* discovered after the applied snapshot would be stranded if the app is killed before discovery finishes.
*/
@Test
fun `pending flag is not cleared while discovery is still running`() = runTest {
val discoveryGate = CompletableDeferred<Unit>()
val repository = FakeAssetsDiscoveryRepository(
initialCurrencies = listOf(appliedCurrency),
pendingWalletIds = listOf(userWalletId),
onRunDiscovery = { discoveryGate.await() },
)
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right()
val useCase = createUseCase(repository)
useCase(userWalletId) // registers an active sync job and parks inside runDiscovery
runCurrent()
useCase.applyPendingAssetsDiscovery()
advanceUntilIdle()
assertThat(repository.clearedPendingFlagFor).isEmpty()
discoveryGate.complete(Unit) // let the discovery job finish so the test can complete cleanly
advanceUntilIdle()
}
/**
* invoke() must not mark the discovery complete when the apply fails: completeDiscovery clears the
* pending flag, so calling it on failure would strand the still-unapplied tokens (no retry possible).
*/
@Test
fun `discovery is not marked complete when apply fails during invoke`() = runTest {
val repository = FakeAssetsDiscoveryRepository(
initialCurrencies = listOf(appliedCurrency),
pendingWalletIds = emptyList(),
)
coEvery {
manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any())
} returns IllegalStateException("apply failed").left()
val useCase = createUseCase(repository)
useCase(userWalletId)
advanceUntilIdle()
assertThat(repository.completedFor).isEmpty()
assertThat(repository.store.map { it.id }).containsExactly(appliedCurrency.id)
}
@Test
fun `discovery is marked complete when apply succeeds during invoke`() = runTest {
val repository = FakeAssetsDiscoveryRepository(
initialCurrencies = listOf(appliedCurrency),
pendingWalletIds = emptyList(),
)
coEvery { manageCryptoCurrenciesUseCase.invokeAndAwait(any(), any(), any(), any()) } returns Unit.right()
val useCase = createUseCase(repository)
useCase(userWalletId)
advanceUntilIdle()
assertThat(repository.completedFor).containsExactly(userWalletId)
assertThat(repository.store).isEmpty()
}
private fun TestScope.createUseCase(
repository: AssetsDiscoveryRepository,
): StartAssetsDiscoveryUseCase = StartAssetsDiscoveryUseCase(
assetsDiscoveryRepository = repository,
manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase,
appCoroutineScope = TestAppCoroutineScope(this),
analyticsEventHandler = analyticsEventHandler,
)
private class FakeAssetsDiscoveryRepository(
initialCurrencies: List<CryptoCurrency>,
private val pendingWalletIds: List<UserWalletId>,
private val onRunDiscovery: suspend () -> Unit = {},
) : AssetsDiscoveryRepository {
val store: MutableList<CryptoCurrency> = initialCurrencies.toMutableList()
val clearedPendingFlagFor: MutableList<UserWalletId> = mutableListOf()
val completedFor: MutableList<UserWalletId> = mutableListOf()
override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> = store.toList()
override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
val appliedIds = currencies.mapTo(hashSetOf(), CryptoCurrency::id)
store.removeAll { it.id in appliedIds }
}
override suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) {
store.clear()
}
override suspend fun getPendingDiscoveryWalletIds(): List<UserWalletId> = pendingWalletIds
override suspend fun clearPendingFlag(userWalletId: UserWalletId) {
clearedPendingFlagFor.add(userWalletId)
}
override suspend fun runDiscovery(userWalletId: UserWalletId) = onRunDiscovery()
override suspend fun completeDiscovery(userWalletId: UserWalletId) {
completedFor.add(userWalletId)
}
override fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow<AssetsDiscoveryProgress> = emptyFlow()
override fun acknowledgeCompletion(userWalletId: UserWalletId) = Unit
}
}