Updated on 2026-08-14
This commit is contained in:
parent
9424928e3e
commit
2ed66a5049
7 changed files with 264 additions and 5 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
@ -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() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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>)
|
||||||
}
|
}
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue