From 2ed66a504944a4d63b768e91b2f0d356fb5b4ea6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 16 Jun 2026 16:26:46 +0500 Subject: [PATCH] Updated on 2026-08-14 --- .../DefaultAssetsDiscoveryRepository.kt | 17 ++ .../store/AssetsDiscoveryStore.kt | 2 + .../store/DefaultAssetsDiscoveryStore.kt | 6 + domain/assetsdiscovery/build.gradle.kts | 10 + .../repository/AssetsDiscoveryRepository.kt | 2 + .../usecase/StartAssetsDiscoveryUseCase.kt | 17 +- .../StartAssetsDiscoveryUseCaseTest.kt | 215 ++++++++++++++++++ 7 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 domain/assetsdiscovery/src/test/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCaseTest.kt diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt index 1f05ac7237..d0d3ef1ccd 100644 --- a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt @@ -78,6 +78,23 @@ internal class DefaultAssetsDiscoveryRepository( assetsDiscoveryStore.clear() } + override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List) { + 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) { setPendingFlag(userWalletId, value = false) } diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt index fa1b8c658e..5798e65444 100644 --- a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt @@ -8,5 +8,7 @@ interface AssetsDiscoveryStore { suspend fun append(tokens: List) + suspend fun removeMatching(predicate: (UserTokensResponse.Token) -> Boolean) + suspend fun clear() } \ No newline at end of file diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt index 9bc4d75f7f..740df1181c 100644 --- a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt @@ -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() { persistenceStore.updateData { emptyList() } } diff --git a/domain/assetsdiscovery/build.gradle.kts b/domain/assetsdiscovery/build.gradle.kts index 6bea487412..9f3d15b8d5 100644 --- a/domain/assetsdiscovery/build.gradle.kts +++ b/domain/assetsdiscovery/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.domain.assetsdiscovery" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.domain.core) implementation(projects.domain.models) @@ -19,4 +23,10 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) + + // region Tests + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) + // endregion } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt index cf9020c3c1..433bef12bf 100644 --- a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt @@ -22,4 +22,6 @@ interface AssetsDiscoveryRepository { suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) + + suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List) } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt index 5ba4a8ffe6..e889aa89a0 100644 --- a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt @@ -28,9 +28,10 @@ class StartAssetsDiscoveryUseCase( try { analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted()) assetsDiscoveryRepository.runDiscovery(userWalletId) - applyDiscoveredTokens(userWalletId) - assetsDiscoveryRepository.completeDiscovery(userWalletId) - analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted()) + if (applyDiscoveredTokens(userWalletId)) { + assetsDiscoveryRepository.completeDiscovery(userWalletId) + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted()) + } } catch (e: Exception) { TangemLogger.e("Token sync failed for wallet: $userWalletId", e) } finally { @@ -51,7 +52,13 @@ class StartAssetsDiscoveryUseCase( val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds() for (walletId in pendingIds) { 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) } } @@ -72,7 +79,7 @@ class StartAssetsDiscoveryUseCase( add = currencies, ).fold( ifRight = { - assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId) + assetsDiscoveryRepository.removeAppliedCurrencies(userWalletId, currencies) true }, ifLeft = { error -> diff --git a/domain/assetsdiscovery/src/test/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCaseTest.kt b/domain/assetsdiscovery/src/test/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCaseTest.kt new file mode 100644 index 0000000000..1a7ba13030 --- /dev/null +++ b/domain/assetsdiscovery/src/test/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCaseTest.kt @@ -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() + private val analyticsEventHandler = mockk(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() + 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, + private val pendingWalletIds: List, + private val onRunDiscovery: suspend () -> Unit = {}, + ) : AssetsDiscoveryRepository { + + val store: MutableList = initialCurrencies.toMutableList() + val clearedPendingFlagFor: MutableList = mutableListOf() + val completedFor: MutableList = mutableListOf() + + override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List = store.toList() + + override suspend fun removeAppliedCurrencies(userWalletId: UserWalletId, currencies: List) { + 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 = 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 = emptyFlow() + + override fun acknowledgeCompletion(userWalletId: UserWalletId) = Unit + } +} \ No newline at end of file