Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-09 18:02:52 +05:00
parent 7832fae9f3
commit e2026ec410
5 changed files with 527 additions and 30 deletions

View file

@ -121,8 +121,8 @@ internal class DefaultNFTRuntimeStore(
},
)
}
val assetsCount = when (assets) {
is NFTCollection.Assets.Value -> assets.items.size
val assetsCount = when {
assets is NFTCollection.Assets.Value && assets.items.isNotEmpty() -> assets.items.size
else -> data.count
}
data.copy(

View file

@ -3,6 +3,7 @@ package com.tangem.datasource.local.nft
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import com.squareup.moshi.Moshi
import com.tangem.blockchain.nft.models.NFTCollection
@ -58,6 +59,7 @@ class NFTPersistenceStoreFactory @Inject constructor(
types = types,
defaultValue = defaultValue,
),
corruptionHandler = ReplaceFileCorruptionHandler { defaultValue },
produceFile = { context.dataStoreFile(fileName = fileName) },
scope = appScope,
)

View file

@ -0,0 +1,136 @@
package com.tangem.datasource.local.nft
import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.Network
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.NFTSalePrice
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.math.BigInteger
class DefaultNFTRuntimeStoreTest {
private val network = createNetwork()
private val store = DefaultNFTRuntimeStore(
network = network,
collectionsRuntimeStore = RuntimeSharedStore(),
pricesRuntimeStore = RuntimeSharedStore(),
)
@Test
fun `GIVEN collection with empty loaded assets WHEN getCollections THEN collection is kept`() = runTest {
// Arrange
val collection = createCollection(
count = 1,
assets = NFTCollection.Assets.Value(items = emptyList(), source = StatusSource.ACTUAL),
)
store.initialize(collections = createCollections(collection), prices = emptyMap())
// Act
val content = store.getCollections().first().content
// Assert
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
content as NFTCollections.Content.Collections
val actual = content.collections.orEmpty().single()
assertThat(actual.count).isEqualTo(1)
assertThat(actual.assets).isInstanceOf(NFTCollection.Assets.Value::class.java)
}
@Test
fun `GIVEN collection with loaded assets WHEN getCollections THEN count recalculated from assets`() = runTest {
// Arrange
val collection = createCollection(
count = 5,
assets = NFTCollection.Assets.Value(items = listOf(createAsset()), source = StatusSource.ACTUAL),
)
store.initialize(collections = createCollections(collection), prices = emptyMap())
// Act
val content = store.getCollections().first().content
// Assert
content as NFTCollections.Content.Collections
assertThat(content.collections.orEmpty().single().count).isEqualTo(1)
}
@Test
fun `GIVEN collection with zero count and not loaded assets WHEN getCollections THEN collection filtered out`() =
runTest {
// Arrange
val collection = createCollection(count = 0, assets = NFTCollection.Assets.Empty)
store.initialize(collections = createCollections(collection), prices = emptyMap())
// Act
val content = store.getCollections().first().content
// Assert
content as NFTCollections.Content.Collections
assertThat(content.collections.orEmpty()).isEmpty()
}
private fun createCollections(vararg collections: NFTCollection) = NFTCollections(
network = network,
content = NFTCollections.Content.Collections(
collections = collections.toList(),
source = StatusSource.ACTUAL,
),
)
private fun createCollection(count: Int, assets: NFTCollection.Assets) = NFTCollection(
id = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
network = network,
name = "Test collection",
description = null,
logoUrl = null,
count = count,
assets = assets,
)
private fun createAsset(): NFTAsset {
val assetId = NFTAsset.Identifier.EVM(
tokenAddress = TOKEN_ADDRESS,
tokenId = BigInteger.ONE,
contractType = NFTAsset.Identifier.EVM.ContractType.ERC721,
)
return NFTAsset(
id = assetId,
collectionId = NFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
network = network,
contractType = "ERC721",
owner = null,
name = "Test asset",
description = null,
amount = null,
decimals = 0,
salePrice = NFTSalePrice.Empty(assetId),
rarity = null,
media = null,
traits = emptyList(),
source = StatusSource.ACTUAL,
)
}
private fun createNetwork() = Network(
id = Network.ID(rawId = Network.RawID("ethereum"), derivationPath = Network.DerivationPath.None),
name = "Ethereum",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.ERC20,
hasFiatFeeRate = true,
canHandleTokens = true,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
private companion object {
const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001"
}
}

View file

@ -12,6 +12,7 @@ import com.tangem.datasource.local.nft.NFTPersistenceStore
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
import com.tangem.datasource.local.nft.NFTRuntimeStore
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter
import com.tangem.datasource.local.nft.converter.NFTSdkAssetIdentifierConverter
import com.tangem.datasource.local.nft.converter.NFTSdkAssetSalePriceConverter
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionConverter
@ -181,29 +182,27 @@ internal class DefaultNFTRepository @Inject constructor(
}
}
getNFTPersistenceStore(userWalletId, network)
.getCollectionsSync()
?.map { collection ->
if (collection.identifier == sdkCollectionId) {
collection.copy(assets = assets)
} else {
collection
}
}
?.let { collections ->
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = collections,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = collections,
)
}
saveAssetsInRuntime(
userWalletId = userWalletId,
network = network,
collectionId = collectionId,
assets = assets,
)
// local cache failures must not affect the runtime state which is already up to date
runSuspendCatching {
updateAssetsInPersistence(
userWalletId = userWalletId,
network = network,
sdkCollectionId = sdkCollectionId,
assets = assets,
)
}.onFailure { error ->
TangemLogger.e("Failed to persist NFT assets for $network", error)
}
}.onLeft { throwable ->
if (throwable !is UnsupportedOperationException) {
TangemLogger.e("Failed to refresh NFT assets for $network", throwable)
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
@ -269,18 +268,29 @@ internal class DefaultNFTRepository @Inject constructor(
expireCollections(userWalletId, network)
val collections = walletManagersFacade.getNFTCollections(userWalletId, network)
val mergedCollections = collections.mergeWithStoredAssets(userWalletId, network)
// local cache failures must not affect successfully fetched collections
val mergedCollections = runSuspendCatching {
collections.mergeWithStoredAssets(userWalletId, network)
}.getOrElse { error ->
TangemLogger.e("Failed to merge NFT collections with stored assets for $network", error)
collections
}
saveCollectionsInRuntime(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
runSuspendCatching {
saveCollectionsInPersistence(
userWalletId = userWalletId,
network = network,
collections = mergedCollections,
)
}.onFailure { error ->
TangemLogger.e("Failed to persist NFT collections for $network", error)
}
if (refreshAssets) {
mergedCollections.forEach { collection ->
@ -292,6 +302,7 @@ internal class DefaultNFTRepository @Inject constructor(
}
}
}.onLeft { throwable ->
TangemLogger.e("Failed to refresh NFT collections for $network", throwable)
saveFailedStateInRuntime(
userWalletId = userWalletId,
network = network,
@ -391,12 +402,72 @@ internal class DefaultNFTRepository @Inject constructor(
}
}
private suspend fun saveAssetsInRuntime(
userWalletId: UserWalletId,
network: Network,
collectionId: NFTCollection.Identifier,
assets: List<SdkNFTAsset>,
) {
val store = getNFTRuntimeStore(userWalletId, network)
val storedCollections = store.getCollectionsSync()
val content = storedCollections.content as? NFTCollections.Content.Collections ?: return
val convertedAssets = assets
.map { asset -> NFTSdkAssetConverter.convert(network to asset) }
.filter { it.id !is NFTAsset.Identifier.Unknown }
val updatedCollections = content.collections
?.map { collection ->
if (collection.id == collectionId) {
collection.copy(
assets = NFTCollection.Assets.Value(
items = convertedAssets,
source = StatusSource.ACTUAL,
),
)
} else {
collection
}
}
store.saveCollections(
storedCollections.copy(content = content.copy(collections = updatedCollections)),
)
}
private suspend fun updateAssetsInPersistence(
userWalletId: UserWalletId,
network: Network,
sdkCollectionId: SdkNFTCollection.Identifier,
assets: List<SdkNFTAsset>,
) {
val storedCollections = getNFTPersistenceStore(userWalletId, network).getCollectionsSync() ?: return
val updatedCollections = storedCollections.map { collection ->
if (collection.identifier == sdkCollectionId) {
collection.copy(assets = assets)
} else {
collection
}
}
saveCollectionsInPersistence(userWalletId, network, updatedCollections)
}
private suspend fun saveCollectionsInPersistence(
userWalletId: UserWalletId,
network: Network,
collections: List<SdkNFTCollection>,
) {
getNFTPersistenceStore(userWalletId, network).saveCollections(collections)
val serializableCollections = collections
.filter { it.identifier !is SdkNFTCollection.Identifier.Unknown }
.map { collection ->
collection.copy(
assets = collection.assets.filter { asset ->
asset.identifier !is SdkNFTAsset.Identifier.Unknown &&
asset.collectionIdentifier !is SdkNFTCollection.Identifier.Unknown
},
)
}
getNFTPersistenceStore(userWalletId, network).saveCollections(serializableCollections)
}
private suspend fun saveSalePriceInRuntime(userWalletId: UserWalletId, network: Network, salePrice: NFTSalePrice) {

View file

@ -0,0 +1,288 @@
package com.tangem.data.nft
import android.content.Context
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.datasource.local.nft.NFTPersistenceStore
import com.tangem.datasource.local.nft.NFTPersistenceStoreFactory
import com.tangem.datasource.local.nft.NFTRuntimeStore
import com.tangem.datasource.local.nft.NFTRuntimeStoreFactory
import com.tangem.datasource.local.nft.converter.NFTSdkCollectionIdentifierConverter
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.nft.models.NFTAsset
import com.tangem.domain.nft.models.NFTCollection
import com.tangem.domain.nft.models.NFTCollections
import com.tangem.domain.nft.models.NFTSalePrice
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.io.IOException
import java.math.BigInteger
import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset
import com.tangem.blockchain.nft.models.NFTCollection as SdkNFTCollection
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultNFTRepositoryTest {
private val nftPersistenceStoreFactory: NFTPersistenceStoreFactory = mockk()
private val nftRuntimeStoreFactory: NFTRuntimeStoreFactory = mockk()
private val walletManagersFacade: WalletManagersFacade = mockk()
private val userWalletsListRepository: UserWalletsListRepository = mockk()
private val context: Context = mockk()
private val userWalletId = UserWalletId("011")
private val userWallet = mockk<UserWallet.Hot> {
every { walletId } returns userWalletId
}
private val network: Network = MockCryptoCurrencyFactory().ethereum.network
@BeforeEach
fun resetMocks() {
clearMocks(nftPersistenceStoreFactory, nftRuntimeStoreFactory, walletManagersFacade, userWalletsListRepository)
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
every { context.resources } returns mockk()
}
private fun createRepository() = DefaultNFTRepository(
nftPersistenceStoreFactory = nftPersistenceStoreFactory,
nftRuntimeStoreFactory = nftRuntimeStoreFactory,
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(),
userWalletsListRepository = userWalletsListRepository,
networkFactory = mockk(),
excludedBlockchains = ExcludedBlockchains(),
context = context,
)
@Test
fun `GIVEN collections fetched WHEN persistence write fails THEN runtime keeps actual data`() = runTest {
// Arrange
val runtimeStore = FakeNFTRuntimeStore(network)
val persistenceStore = mockk<NFTPersistenceStore> {
coEvery { getCollectionsSync() } returns null
coEvery { getSalePricesSync() } returns null
coEvery { saveCollections(any()) } throws IOException("Failed to write to disk")
}
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } returns listOf(createSdkCollection())
// Act
createRepository().refreshCollections(userWalletId, listOf(network))
// Assert
val content = runtimeStore.getCollectionsSync().content
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
content as NFTCollections.Content.Collections
assertThat(content.source).isEqualTo(StatusSource.ACTUAL)
assertThat(content.collections).hasSize(1)
}
@Test
fun `GIVEN runtime has collection missing in persistence WHEN refreshAssets THEN assets saved to runtime`() =
runTest {
// Arrange
val sdkCollection = createSdkCollection()
val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier)
val runtimeStore = FakeNFTRuntimeStore(network)
val persistenceStore = mockk<NFTPersistenceStore> {
coEvery { getCollectionsSync() } returns null
coEvery { getSalePricesSync() } returns null
coEvery { saveCollections(any()) } returns Unit
}
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
coEvery {
walletManagersFacade.getNFTCollections(userWalletId, network)
} returns listOf(sdkCollection)
coEvery {
walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier)
} returns listOf(createSdkAsset())
coEvery { walletManagersFacade.getNFTSalePrice(userWalletId, network, any(), any()) } returns null
val repository = createRepository()
// seed runtime store with the fetched collection, persistence stays empty
repository.refreshCollections(userWalletId, listOf(network))
// Act
repository.refreshAssets(userWalletId, network, collectionId)
// Assert
val content = runtimeStore.getCollectionsSync().content
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
content as NFTCollections.Content.Collections
val assets = content.collections.orEmpty().single().assets
assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java)
assets as NFTCollection.Assets.Value
assertThat(assets.items).hasSize(1)
}
@Test
fun `GIVEN fetch returns no assets WHEN refreshAssets THEN empty loaded value saved to runtime`() = runTest {
// Arrange
val sdkCollection = createSdkCollection()
val collectionId = NFTSdkCollectionIdentifierConverter.convert(sdkCollection.identifier)
val runtimeStore = FakeNFTRuntimeStore(network)
val persistenceStore = mockk<NFTPersistenceStore> {
coEvery { getCollectionsSync() } returns null
coEvery { getSalePricesSync() } returns null
coEvery { saveCollections(any()) } returns Unit
}
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
coEvery {
walletManagersFacade.getNFTCollections(userWalletId, network)
} returns listOf(sdkCollection)
coEvery {
walletManagersFacade.getNFTAssets(userWalletId, network, sdkCollection.identifier)
} returns emptyList()
val repository = createRepository()
repository.refreshCollections(userWalletId, listOf(network))
// Act
repository.refreshAssets(userWalletId, network, collectionId)
// Assert
val content = runtimeStore.getCollectionsSync().content
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
content as NFTCollections.Content.Collections
val assets = content.collections.orEmpty().single().assets
assertThat(assets).isInstanceOf(NFTCollection.Assets.Value::class.java)
assets as NFTCollection.Assets.Value
assertThat(assets.items).isEmpty()
}
@Test
fun `GIVEN no cached collections WHEN fetch fails THEN error state saved to runtime`() = runTest {
// Arrange
val runtimeStore = FakeNFTRuntimeStore(network)
val persistenceStore = mockk<NFTPersistenceStore> {
coEvery { getCollectionsSync() } returns null
coEvery { getSalePricesSync() } returns null
}
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500")
// Act
createRepository().refreshCollections(userWalletId, listOf(network))
// Assert
val content = runtimeStore.getCollectionsSync().content
assertThat(content).isInstanceOf(NFTCollections.Content.Error::class.java)
}
@Test
fun `GIVEN cached collections WHEN fetch fails THEN cache marked as only cache`() = runTest {
// Arrange
val runtimeStore = FakeNFTRuntimeStore(network)
val persistenceStore = mockk<NFTPersistenceStore> {
coEvery { getCollectionsSync() } returns listOf(createSdkCollection())
coEvery { getSalePricesSync() } returns null
}
every { nftPersistenceStoreFactory.provide(userWalletId, network) } returns persistenceStore
every { nftRuntimeStoreFactory.provide(network) } returns runtimeStore
coEvery { walletManagersFacade.getNFTCollections(userWalletId, network) } throws IOException("HTTP 500")
// Act
createRepository().refreshCollections(userWalletId, listOf(network))
// Assert
val content = runtimeStore.getCollectionsSync().content
assertThat(content).isInstanceOf(NFTCollections.Content.Collections::class.java)
content as NFTCollections.Content.Collections
assertThat(content.source).isEqualTo(StatusSource.ONLY_CACHE)
assertThat(content.collections).hasSize(1)
}
private fun createSdkCollection(assets: List<SdkNFTAsset> = emptyList()) = SdkNFTCollection(
identifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
blockchainId = Blockchain.Ethereum.id,
name = "Test collection",
description = null,
logoUrl = null,
count = 1,
assets = assets,
)
private fun createSdkAsset() = SdkNFTAsset(
identifier = SdkNFTAsset.Identifier.EVM(
tokenId = BigInteger.ONE,
tokenAddress = TOKEN_ADDRESS,
contractType = SdkNFTAsset.Identifier.EVM.ContractType.ERC721,
),
collectionIdentifier = SdkNFTCollection.Identifier.EVM(tokenAddress = TOKEN_ADDRESS),
blockchainId = Blockchain.Ethereum.id,
contractType = "ERC721",
owner = null,
name = "Test asset",
description = null,
amount = BigInteger.ONE,
decimals = 0,
salePrice = null,
rarity = null,
media = null,
traits = emptyList(),
)
private class FakeNFTRuntimeStore(private val network: Network) : NFTRuntimeStore {
private var collections: NFTCollections = NFTCollections.empty(network)
private var prices: Map<NFTAsset.Identifier, NFTSalePrice> = emptyMap()
override suspend fun initialize(collections: NFTCollections, prices: Map<NFTAsset.Identifier, NFTSalePrice>) {
this.collections = collections
this.prices = prices
}
override fun getCollections(): Flow<NFTCollections> = flowOf(collections)
override suspend fun getCollectionsSync(): NFTCollections = collections
override fun getAsset(
collectionId: NFTCollection.Identifier,
assetId: NFTAsset.Identifier,
): Flow<NFTAsset?> = flowOf(null)
override fun getSalePrice(assetId: NFTAsset.Identifier): Flow<NFTSalePrice> =
flowOf(prices[assetId] ?: NFTSalePrice.Empty(assetId))
override suspend fun getSalePriceSync(assetId: NFTAsset.Identifier): NFTSalePrice =
prices[assetId] ?: NFTSalePrice.Empty(assetId)
override suspend fun saveCollections(collections: NFTCollections) {
this.collections = collections
}
override suspend fun saveSalePrice(salePrice: NFTSalePrice) {
prices = prices + (salePrice.assetId to salePrice)
}
override suspend fun clear() {
collections = NFTCollections.empty(network)
prices = emptyMap()
}
}
private companion object {
const val TOKEN_ADDRESS = "0x0000000000000000000000000000000000000001"
}
}