Updated on 2026-08-14
This commit is contained in:
parent
9efe573323
commit
9af7bad7b8
22 changed files with 1680 additions and 238 deletions
|
|
@ -53,6 +53,8 @@ dependencies {
|
|||
api(projects.domain.walletManager)
|
||||
api(projects.domain.wallets)
|
||||
implementation(projects.domain.account.status)
|
||||
implementation(projects.domain.legacy)
|
||||
implementation(projects.domain.visa)
|
||||
// endregion
|
||||
|
||||
// region Domain models
|
||||
|
|
@ -60,6 +62,8 @@ dependencies {
|
|||
implementation(projects.domain.onramp.models)
|
||||
implementation(projects.domain.tokens.models)
|
||||
implementation(projects.domain.txhistory.models)
|
||||
implementation(projects.domain.visa.models)
|
||||
implementation(projects.domain.wallets.models)
|
||||
// endregion
|
||||
|
||||
// region Libs
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import com.tangem.data.common.txhistory.ExpressHistoryRepository
|
|||
import com.tangem.data.txhistory.fetcher.DefaultAppTxHistoryFetcher
|
||||
import com.tangem.data.txhistory.fetcher.DefaultTxHistoryFetcherUtils
|
||||
import com.tangem.data.txhistory.fetcher.TxHistoryFetcherUtils
|
||||
import com.tangem.data.txhistory.list.DefaultHistoryTxListManager
|
||||
import com.tangem.data.txhistory.repository.DefaultExpressHistoryRepository
|
||||
import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository
|
||||
import com.tangem.data.txhistory.repository.RefactoredTxHistoryRepository
|
||||
import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepository
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import dagger.Binds
|
||||
|
|
@ -38,4 +40,7 @@ internal interface TxHistoryDataModule {
|
|||
@Binds
|
||||
@Singleton
|
||||
fun provideExpressHistoryRepository(default: DefaultExpressHistoryRepository): ExpressHistoryRepository
|
||||
|
||||
@Binds
|
||||
fun provideHistoryTxListManagerFactory(default: DefaultHistoryTxListManager.Factory): HistoryTxListManager.Factory
|
||||
}
|
||||
|
|
@ -2,13 +2,13 @@ package com.tangem.data.txhistory.fetcher
|
|||
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
|
||||
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
|
||||
import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.plus
|
||||
import javax.inject.Inject
|
||||
|
||||
const val TX_HISTORY_TAG = "TxHistory"
|
||||
|
|
@ -30,6 +30,11 @@ internal interface TxHistoryFetcherUtils {
|
|||
fun <T> TxHistoryFetcherUtils.defaultLaunchIn(flow: Flow<T>) = flow
|
||||
.retry { error ->
|
||||
logError(error)
|
||||
val event = ExceptionAnalyticsEvent(
|
||||
exception = error,
|
||||
params = mapOf("source" to TX_HISTORY_TAG),
|
||||
)
|
||||
analyticsExceptionHandler.sendException(event)
|
||||
true
|
||||
}
|
||||
.launchIn(fetcherScope)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package com.tangem.data.txhistory.list
|
||||
|
||||
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
|
||||
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
|
||||
import com.tangem.data.txhistory.fetcher.TX_HISTORY_TAG
|
||||
import com.tangem.data.txhistory.list.chain.*
|
||||
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
|
||||
import com.tangem.domain.express.ExpressServiceFetcher
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.*
|
||||
import com.tangem.domain.txhistory.models.TxHistoryStateError
|
||||
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultHistoryTxListManager @AssistedInject constructor(
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
private val expressServiceFetcher: ExpressServiceFetcher,
|
||||
private val paymentAccountCurrency: GetPaymentAccountCryptoCurrencyStatusUseCase,
|
||||
private val getAccountCryptoCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
|
||||
private val txHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
|
||||
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
|
||||
private val bsdkOnChainHistoryFactory: BsdkOnChainHistory.Factory,
|
||||
private val tangemPayOnChainHistoryFactory: TangemPayOnChainHistory.Factory,
|
||||
private val indexTableOnChainHistoryFactory: IndexTableOnChainHistory.Factory,
|
||||
@Assisted private val userWalletId: UserWalletId,
|
||||
@Assisted private val currency: CryptoCurrency,
|
||||
@Assisted private val modelScope: CoroutineScope,
|
||||
) : HistoryTxListManager {
|
||||
|
||||
private val actionsFlow: Channel<Action> = Channel()
|
||||
private val _historySources: MutableSharedFlow<HistorySources> = MutableSharedFlow(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val state: StateFlow<HistoryState>
|
||||
override val historySources: Flow<HistorySources> get() = _historySources.distinctUntilChanged()
|
||||
|
||||
init {
|
||||
state = buildPipeline()
|
||||
.retry { error ->
|
||||
logError(error)
|
||||
true
|
||||
}
|
||||
.flowOn(dispatchers.io)
|
||||
.stateIn(modelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||
}
|
||||
|
||||
override fun reload() {
|
||||
actionsFlow.trySend(Action.Reload(shouldRefresh = true))
|
||||
}
|
||||
|
||||
override fun loadMore() {
|
||||
actionsFlow.trySend(Action.LoadMore)
|
||||
}
|
||||
|
||||
private fun buildPipeline() = channelFlow {
|
||||
val reloadInitialLoading = flow {
|
||||
// initial load
|
||||
emit(Unit)
|
||||
// any action triggers a reload
|
||||
emitAll(actionsFlow.receiveAsFlow().map { })
|
||||
}
|
||||
val historySources = reloadInitialLoading
|
||||
.onEach { channel.send(HistoryState.Loading) }
|
||||
.mapNotNull {
|
||||
val result = runSuspendCatching { loadSources() }.getOrNull()
|
||||
// Error state, wait for any action to reload
|
||||
if (result == null) channel.send(HistoryState.Error)
|
||||
result
|
||||
}
|
||||
.onEach { _historySources.tryEmit(it) }
|
||||
.first()
|
||||
if (!isHistoryAvailable(historySources)) {
|
||||
channel.send(HistoryState.Unavailable)
|
||||
return@channelFlow
|
||||
}
|
||||
|
||||
val env = HistoryEnvironment(
|
||||
userWalletId = userWalletId,
|
||||
currency = currency,
|
||||
modelScope = modelScope,
|
||||
)
|
||||
val onChainHistory: OnChainHistory = when (historySources.onChainSource) {
|
||||
OnChainSource.BSDK -> bsdkOnChainHistoryFactory.create(env)
|
||||
OnChainSource.TangemPay -> tangemPayOnChainHistoryFactory.create(env)
|
||||
OnChainSource.IndexTable -> indexTableOnChainHistoryFactory.create(env)
|
||||
}
|
||||
|
||||
actionsFlow.receiveAsFlow()
|
||||
.onEach { onChainHistory.sendAction(it) }
|
||||
.launchIn(this)
|
||||
|
||||
onChainHistory.history().collect { channel.send(it) }
|
||||
}
|
||||
|
||||
private suspend fun loadSources(): HistorySources = coroutineScope {
|
||||
val onChainSource = async { resolveOnChainSource() }
|
||||
val expressAsset = async { awaitExpressAsset() }
|
||||
|
||||
val asset = expressAsset.await()
|
||||
HistorySources(
|
||||
onChainSource = onChainSource.await(),
|
||||
isExchangeAvailable = asset?.isExchangeAvailable == true,
|
||||
isOnrampAvailable = asset?.isOnrampAvailable == true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isHistoryAvailable(sources: HistorySources): Boolean = with(sources) {
|
||||
when (onChainSource) {
|
||||
OnChainSource.BSDK -> true
|
||||
OnChainSource.TangemPay -> true
|
||||
OnChainSource.IndexTable -> isExchangeAvailable || isOnrampAvailable
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveOnChainSource(): OnChainSource {
|
||||
val isCryptoPortfolio = getAccountCryptoCurrencyStatusUseCase.invokeSync(userWalletId, currency).isSome()
|
||||
return when {
|
||||
isCryptoPortfolio -> txHistoryItemsCountUseCase(userWalletId, currency).fold(
|
||||
ifLeft = { error ->
|
||||
when (error) {
|
||||
is TxHistoryStateError.DataError -> throw error
|
||||
TxHistoryStateError.EmptyTxHistories -> OnChainSource.BSDK
|
||||
TxHistoryStateError.TxHistoryNotImplemented -> OnChainSource.IndexTable
|
||||
}
|
||||
},
|
||||
ifRight = { OnChainSource.BSDK },
|
||||
)
|
||||
|
||||
paymentAccountCurrency.invokeSync(userWalletId, currency).isSome() -> OnChainSource.TangemPay
|
||||
else -> OnChainSource.IndexTable
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitExpressAsset(): ExpressAsset? {
|
||||
val assetId = ExpressAsset.ID(currency)
|
||||
return expressServiceFetcher.getOrFetch(userWalletId, assetId).getOrNull()
|
||||
}
|
||||
|
||||
private fun logError(error: Throwable) {
|
||||
val message = error.message.orEmpty()
|
||||
TangemLogger.withTag(TX_HISTORY_TAG).e(message, error)
|
||||
val event = ExceptionAnalyticsEvent(
|
||||
exception = error,
|
||||
params = mapOf("source" to TX_HISTORY_TAG),
|
||||
)
|
||||
analyticsExceptionHandler.sendException(event)
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : HistoryTxListManager.Factory {
|
||||
override fun create(
|
||||
userWalletId: UserWalletId,
|
||||
currency: CryptoCurrency,
|
||||
modelScope: CoroutineScope,
|
||||
): DefaultHistoryTxListManager
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package com.tangem.data.txhistory.list
|
||||
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.model.OnChainTx
|
||||
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||
import com.tangem.domain.txhistory.model.explorerHash
|
||||
|
||||
/**
|
||||
* Merges the on-chain pagination backbone with the express (swap/onramp) overlay into a single
|
||||
* timestamp-DESC timeline.
|
||||
*
|
||||
* Per express op (matched to on-chain by [ExpressTx.matchHash]):
|
||||
* - matched → enrich: emit the express row carrying its on-chain leg; the on-chain tx(es)
|
||||
* of that hash are collapsed into this row (not emitted standalone).
|
||||
* - unmatched → standalone row (status shown, no on-chain leg). Both in-progress and terminal
|
||||
* (finished/failed) express ops are shown so the user always sees their deals.
|
||||
*
|
||||
* On-chain transactions that no express op claimed pass through as [OnChainTx].
|
||||
* [onChain] is expected to be already de-duplicated (by `identityKey`) by the caller.
|
||||
*/
|
||||
internal fun mergeTxHistoryInfos(onChain: List<TxInfo>, express: List<ExpressTx>): List<TxHistoryInfo> {
|
||||
val onChainByHash = onChain.associateBy { it.txHash }
|
||||
val matchedHashes = mutableSetOf<String>()
|
||||
val result = mutableListOf<TxHistoryInfo>()
|
||||
|
||||
express.forEach { op ->
|
||||
val matched = op.matchHash?.let(onChainByHash::get)
|
||||
if (matched != null) {
|
||||
result += op.withMatchedOnChain(OnChainTx.BSDK(matched))
|
||||
matchedHashes += matched.txHash
|
||||
} else {
|
||||
result += op
|
||||
}
|
||||
}
|
||||
|
||||
onChain.forEach { tx ->
|
||||
if (tx.txHash !in matchedHashes) {
|
||||
result += OnChainTx.BSDK(tx)
|
||||
}
|
||||
}
|
||||
|
||||
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
|
||||
}
|
||||
|
||||
/**
|
||||
* TangemPay counterpart of [mergeTxHistoryInfos]: merges the TangemPay on-chain backbone with the
|
||||
* express overlay. Same rules as [mergeTxHistoryInfos] — matched express ops enrich (carry their
|
||||
* on-chain leg and collapse it), unmatched ops stay standalone, unclaimed on-chain rows pass through.
|
||||
*
|
||||
* TangemPay rows are matched by [OnChainTx.explorerHash] (the item's `transactionHash`) against the
|
||||
* express op's [ExpressTx.matchHash].
|
||||
*
|
||||
* WARNING: this hash-based match has NOT been validated against real TangemPay data yet — TangemPay is
|
||||
* not wired into the history end-to-end. Re-verify the hash semantics (which field carries the on-chain
|
||||
* hash, and that it lines up with the express payin/payout hash) when TangemPay is integrated for real.
|
||||
*/
|
||||
internal fun mergeTangemPay(onChain: List<OnChainTx.TangemPay>, express: List<ExpressTx>): List<TxHistoryInfo> {
|
||||
val onChainByHash = onChain.mapNotNull { tx -> tx.explorerHash?.let { it to tx } }.toMap()
|
||||
val matchedHashes = mutableSetOf<String>()
|
||||
val result = mutableListOf<TxHistoryInfo>()
|
||||
|
||||
express.forEach { op ->
|
||||
val matched = op.matchHash?.let(onChainByHash::get)
|
||||
if (matched != null) {
|
||||
result += op.withMatchedOnChain(matched)
|
||||
matched.explorerHash?.let(matchedHashes::add)
|
||||
} else {
|
||||
result += op
|
||||
}
|
||||
}
|
||||
|
||||
onChain.forEach { tx ->
|
||||
if (tx.explorerHash !in matchedHashes) {
|
||||
result += tx
|
||||
}
|
||||
}
|
||||
|
||||
return result.sortedByDescending(TxHistoryInfo::timestampMillis)
|
||||
}
|
||||
|
||||
private fun ExpressTx.withMatchedOnChain(onChain: OnChainTx): ExpressTx = when (this) {
|
||||
is ExpressTx.Swap -> copy(txInfo = onChain)
|
||||
is ExpressTx.Onramp -> copy(txInfo = onChain)
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package com.tangem.data.txhistory.list.chain
|
||||
|
||||
import com.tangem.data.txhistory.list.mergeTxHistoryInfos
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
|
||||
import com.tangem.domain.txhistory.model.TxHistoryListConfig
|
||||
import com.tangem.domain.txhistory.model.identityKey
|
||||
import com.tangem.domain.txhistory.models.PaginationWrapper
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
import com.tangem.pagination.BatchListState
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
private typealias HistoryTxBatchAction = BatchAction<Int, TxHistoryListConfig, Nothing>
|
||||
|
||||
/** On-chain backbone backed by the blockchain SDK history, with the express overlay windowed to the loaded page. */
|
||||
internal class BsdkOnChainHistory @AssistedInject constructor(
|
||||
private val repository: TxHistoryRepositoryV2,
|
||||
@Assisted private val env: HistoryEnvironment,
|
||||
) : OnChainHistory {
|
||||
private val userWalletId get() = env.userWalletId
|
||||
private val currency get() = env.currency
|
||||
|
||||
override val actions: Channel<Action> = Channel()
|
||||
|
||||
override fun history(): Flow<HistoryState> {
|
||||
val actionsFlow = flow {
|
||||
// initial load
|
||||
emit(Action.Reload(shouldRefresh = false))
|
||||
// receive user actions
|
||||
actions.receiveAsFlow().collect { emit(it) }
|
||||
}
|
||||
val batchFlow = repository.getTxHistoryBatchFlow(
|
||||
context = TxHistoryListBatchingContext(
|
||||
actionsFlow = actionsFlow.map { it.toBatchAction() },
|
||||
coroutineScope = env.modelScope,
|
||||
),
|
||||
batchSize = BATCH_SIZE,
|
||||
)
|
||||
|
||||
return batchFlow.state
|
||||
.onEach { batchState -> autoLoadMoreUntilScrollable(batchState) }
|
||||
.flatMapLatest { batchState -> buildState(batchState) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps requesting the next page while the loaded on-chain backbone is too short to make the list scrollable
|
||||
* (fewer than [AUTO_LOAD_MORE_TARGET_COUNT] items) or the last page came back empty. Runs only in the
|
||||
* [PaginationStatus.Paginating] state, so it stops as soon as the backbone reaches [PaginationStatus.EndOfPagination].
|
||||
*/
|
||||
private fun autoLoadMoreUntilScrollable(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>) {
|
||||
val status = batchState.status as? PaginationStatus.Paginating ?: return
|
||||
val lastResult = status.lastResult as? BatchFetchResult.Success ?: return
|
||||
val loadedItemsCount = batchState.data.sumOf { batch -> batch.data.items.size }
|
||||
val shouldLoadMore = loadedItemsCount < AUTO_LOAD_MORE_TARGET_COUNT || lastResult.empty
|
||||
if (shouldLoadMore) {
|
||||
sendAction(Action.LoadMore)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildState(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>): Flow<HistoryState> {
|
||||
val mergedFlow: Flow<List<TxHistoryInfo>> = repository.getExpressHistory(
|
||||
userWalletId = userWalletId,
|
||||
currency = currency,
|
||||
fromCreatedAtMillis = oldestLoadedTimestamp(batchState),
|
||||
).map { express ->
|
||||
val onChain = batchState.data.asSequence()
|
||||
.flatMap { it.data.items.asSequence() }
|
||||
.distinctBy(TxInfo::identityKey)
|
||||
.toList()
|
||||
|
||||
mergeTxHistoryInfos(onChain = onChain, express = express)
|
||||
}
|
||||
|
||||
return when (batchState.status) {
|
||||
PaginationStatus.None,
|
||||
PaginationStatus.InitialLoading,
|
||||
-> flowOf(HistoryState.Loading)
|
||||
// Terminal initial failure: surface Error so the user can retry the on-chain page load.
|
||||
is PaginationStatus.InitialLoadingError -> flowOf(HistoryState.Error)
|
||||
PaginationStatus.NextBatchLoading -> mergedFlow.map { merged ->
|
||||
HistoryState.Content(merged, isLoadingMore = true, hasMore = true)
|
||||
}
|
||||
is PaginationStatus.Paginating<*> -> mergedFlow.map { merged ->
|
||||
if (merged.isEmpty()) {
|
||||
HistoryState.Empty
|
||||
} else {
|
||||
HistoryState.Content(merged, isLoadingMore = false, hasMore = true)
|
||||
}
|
||||
}
|
||||
PaginationStatus.EndOfPagination -> mergedFlow.map { merged ->
|
||||
if (merged.isEmpty()) {
|
||||
HistoryState.Empty
|
||||
} else {
|
||||
HistoryState.Content(merged, isLoadingMore = false, hasMore = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Action.toBatchAction(): HistoryTxBatchAction = when (this) {
|
||||
is Action.Reload -> BatchAction.Reload(
|
||||
TxHistoryListConfig(userWalletId, currency, shouldRefresh = shouldRefresh),
|
||||
)
|
||||
Action.LoadMore -> BatchAction.LoadMore(TxHistoryListConfig(userWalletId, currency, shouldRefresh = false))
|
||||
}
|
||||
|
||||
private fun oldestLoadedTimestamp(batchState: BatchListState<Int, PaginationWrapper<TxInfo>>): Long =
|
||||
batchState.data.asSequence()
|
||||
.flatMap { it.data.items.asSequence() }
|
||||
.minOfOrNull { it.timestampInMillis }
|
||||
?: NO_LOWER_BOUND
|
||||
|
||||
private companion object {
|
||||
const val BATCH_SIZE = 50
|
||||
const val NO_LOWER_BOUND = 0L
|
||||
|
||||
/** Number of loaded items considered enough to make the list scrollable. */
|
||||
const val AUTO_LOAD_MORE_TARGET_COUNT = 20
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(env: HistoryEnvironment): BsdkOnChainHistory
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package com.tangem.data.txhistory.list.chain
|
||||
|
||||
import com.tangem.data.txhistory.list.mergeTxHistoryInfos
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.domain.txhistory.repository.ExpressHistoryPage
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* Backbone for currencies without an on-chain history source: paginates the express (swap/onramp) overlay itself,
|
||||
* using the unified history index. There is no on-chain leg, so every row is a standalone express item.
|
||||
*/
|
||||
internal class IndexTableOnChainHistory @AssistedInject constructor(
|
||||
private val repository: TxHistoryRepositoryV2,
|
||||
@Assisted private val env: HistoryEnvironment,
|
||||
) : OnChainHistory {
|
||||
|
||||
override val actions: Channel<Action> = Channel()
|
||||
|
||||
override fun history(): Flow<HistoryState> = channelFlow {
|
||||
val limit = MutableStateFlow(PAGE_SIZE)
|
||||
actions.receiveAsFlow()
|
||||
.onEach { action ->
|
||||
when (action) {
|
||||
is Action.Reload -> limit.value = PAGE_SIZE
|
||||
Action.LoadMore -> limit.update { it + PAGE_SIZE }
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
|
||||
limit
|
||||
.flatMapLatest { pageLimit ->
|
||||
repository.getIndexedExpressHistory(
|
||||
env.userWalletId,
|
||||
env.currency,
|
||||
pageLimit,
|
||||
)
|
||||
}
|
||||
.map { page -> buildState(page) }
|
||||
.collect { send(it) }
|
||||
}
|
||||
|
||||
private fun buildState(page: ExpressHistoryPage): HistoryState {
|
||||
val merged = mergeTxHistoryInfos(onChain = emptyList(), express = page.items)
|
||||
return if (merged.isEmpty()) {
|
||||
HistoryState.Empty
|
||||
} else {
|
||||
HistoryState.Content(merged, isLoadingMore = false, hasMore = page.hasMore)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 50
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(env: HistoryEnvironment): IndexTableOnChainHistory
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.tangem.data.txhistory.list.chain
|
||||
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.pagination.BatchAction
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** One history backbone (BSDK / TangemPay / index-backed express): consumes [Action]s and emits [HistoryState]. */
|
||||
internal interface OnChainHistory {
|
||||
val actions: Channel<Action>
|
||||
fun sendAction(action: Action) = actions.trySend(action)
|
||||
|
||||
fun history(): Flow<HistoryState>
|
||||
}
|
||||
|
||||
/** Neutral external action, decoupled from the pagination [BatchAction]. */
|
||||
internal sealed interface Action {
|
||||
data class Reload(val shouldRefresh: Boolean) : Action
|
||||
data object LoadMore : Action
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package com.tangem.data.txhistory.list.chain
|
||||
|
||||
import com.tangem.data.txhistory.list.mergeTangemPay
|
||||
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchingContext
|
||||
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListConfig
|
||||
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.domain.txhistory.model.OnChainTx
|
||||
import com.tangem.domain.txhistory.model.TxHistoryInfo
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.BatchListState
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
private typealias TangemPayTxBatchAction = BatchAction<Int, TangemPayTxHistoryListConfig, Nothing>
|
||||
|
||||
/** On-chain backbone backed by the TangemPay tx history, with the express overlay windowed to the loaded page. */
|
||||
internal class TangemPayOnChainHistory @AssistedInject constructor(
|
||||
private val repository: TangemPayTxHistoryRepository,
|
||||
private val txHistoryRepository: TxHistoryRepositoryV2,
|
||||
@Assisted private val env: HistoryEnvironment,
|
||||
) : OnChainHistory {
|
||||
private val userWalletId get() = env.userWalletId
|
||||
private val currency get() = env.currency
|
||||
|
||||
override val actions: Channel<Action> = Channel()
|
||||
|
||||
override fun history(): Flow<HistoryState> {
|
||||
val actionsFlow = flow {
|
||||
// initial load
|
||||
emit(Action.Reload(shouldRefresh = false))
|
||||
// receive user actions
|
||||
actions.receiveAsFlow().collect { emit(it) }
|
||||
}
|
||||
val batchFlow = repository.getTxHistoryBatchFlow(
|
||||
userWalletId = userWalletId,
|
||||
batchSize = BATCH_SIZE,
|
||||
context = TangemPayTxHistoryListBatchingContext(
|
||||
actionsFlow = actionsFlow.map { it.toBatchAction() },
|
||||
coroutineScope = env.modelScope,
|
||||
),
|
||||
)
|
||||
|
||||
return batchFlow.state
|
||||
.flatMapLatest { batchState -> buildState(batchState) }
|
||||
}
|
||||
|
||||
private fun buildState(batchState: BatchListState<Int, List<TangemPayTxHistoryItem>>): Flow<HistoryState> {
|
||||
val mergedFlow: Flow<List<TxHistoryInfo>> = txHistoryRepository.getExpressHistory(
|
||||
userWalletId = userWalletId,
|
||||
currency = currency,
|
||||
fromCreatedAtMillis = oldestLoadedTimestamp(batchState),
|
||||
).map { express ->
|
||||
val onChain = batchState.data.asSequence()
|
||||
.flatMap { it.data.asSequence() }
|
||||
.map(OnChainTx::TangemPay)
|
||||
.toList()
|
||||
|
||||
mergeTangemPay(onChain = onChain, express = express)
|
||||
}
|
||||
|
||||
return when (batchState.status) {
|
||||
PaginationStatus.None,
|
||||
PaginationStatus.InitialLoading,
|
||||
-> flowOf(HistoryState.Loading)
|
||||
// Terminal initial failure: surface Error so the user can retry the on-chain page load.
|
||||
is PaginationStatus.InitialLoadingError -> flowOf(HistoryState.Error)
|
||||
PaginationStatus.NextBatchLoading -> mergedFlow.map { merged ->
|
||||
HistoryState.Content(merged, isLoadingMore = true, hasMore = true)
|
||||
}
|
||||
is PaginationStatus.Paginating<*> -> mergedFlow.map { merged ->
|
||||
if (merged.isEmpty()) {
|
||||
HistoryState.Empty
|
||||
} else {
|
||||
HistoryState.Content(merged, isLoadingMore = false, hasMore = true)
|
||||
}
|
||||
}
|
||||
PaginationStatus.EndOfPagination -> mergedFlow.map { merged ->
|
||||
if (merged.isEmpty()) {
|
||||
HistoryState.Empty
|
||||
} else {
|
||||
HistoryState.Content(merged, isLoadingMore = false, hasMore = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Action.toBatchAction(): TangemPayTxBatchAction = when (this) {
|
||||
is Action.Reload -> BatchAction.Reload(TangemPayTxHistoryListConfig(shouldRefresh = shouldRefresh))
|
||||
Action.LoadMore -> BatchAction.LoadMore(requestParams = null)
|
||||
}
|
||||
|
||||
private fun oldestLoadedTimestamp(batchState: BatchListState<Int, List<TangemPayTxHistoryItem>>): Long =
|
||||
batchState.data.asSequence()
|
||||
.flatMap { it.data.asSequence() }
|
||||
.minOfOrNull { it.date.millis }
|
||||
?: NO_LOWER_BOUND
|
||||
|
||||
private companion object {
|
||||
const val BATCH_SIZE = 50
|
||||
const val NO_LOWER_BOUND = 0L
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(env: HistoryEnvironment): TangemPayOnChainHistory
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
package com.tangem.data.txhistory.list
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.none
|
||||
import arrow.core.right
|
||||
import arrow.core.some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
|
||||
import com.tangem.data.txhistory.list.chain.Action
|
||||
import com.tangem.data.txhistory.list.chain.BsdkOnChainHistory
|
||||
import com.tangem.data.txhistory.list.chain.IndexTableOnChainHistory
|
||||
import com.tangem.data.txhistory.list.chain.TangemPayOnChainHistory
|
||||
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
|
||||
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
|
||||
import com.tangem.domain.express.ExpressServiceFetcher
|
||||
import com.tangem.domain.express.models.ExpressAsset
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistorySources
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.OnChainSource
|
||||
import com.tangem.domain.txhistory.models.TxHistoryStateError
|
||||
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
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
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
|
||||
/**
|
||||
* Verifies the orchestration in [DefaultHistoryTxListManager]: which on-chain backbone is chosen, availability gating,
|
||||
* the universal state sequence (initial load → error → retry → content), analytics on unexpected failures, and
|
||||
* forwarding of external actions to the chosen backbone. The backbones themselves are stubbed (tested separately).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class DefaultHistoryTxListManagerTest {
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "01")
|
||||
private val currency = mockk<CryptoCurrency>(relaxed = true)
|
||||
|
||||
private val expressServiceFetcher = mockk<ExpressServiceFetcher>()
|
||||
private val paymentAccountCurrency = mockk<GetPaymentAccountCryptoCurrencyStatusUseCase>()
|
||||
private val getAccountCurrencyStatusUseCase = mockk<GetAccountCurrencyStatusUseCase>()
|
||||
private val txHistoryItemsCountUseCase = mockk<GetTxHistoryItemsCountUseCase>()
|
||||
private val analyticsExceptionHandler = mockk<AnalyticsExceptionHandler>(relaxed = true)
|
||||
|
||||
private val bsdk = mockk<BsdkOnChainHistory>(relaxed = true)
|
||||
private val tangemPay = mockk<TangemPayOnChainHistory>(relaxed = true)
|
||||
private val indexTable = mockk<IndexTableOnChainHistory>(relaxed = true)
|
||||
private val bsdkFactory = mockk<BsdkOnChainHistory.Factory>()
|
||||
private val tangemPayFactory = mockk<TangemPayOnChainHistory.Factory>()
|
||||
private val indexTableFactory = mockk<IndexTableOnChainHistory.Factory>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(
|
||||
expressServiceFetcher,
|
||||
paymentAccountCurrency,
|
||||
getAccountCurrencyStatusUseCase,
|
||||
txHistoryItemsCountUseCase,
|
||||
analyticsExceptionHandler,
|
||||
bsdk,
|
||||
tangemPay,
|
||||
indexTable,
|
||||
bsdkFactory,
|
||||
tangemPayFactory,
|
||||
indexTableFactory,
|
||||
)
|
||||
every { bsdkFactory.create(any()) } returns bsdk
|
||||
every { tangemPayFactory.create(any()) } returns tangemPay
|
||||
every { indexTableFactory.create(any()) } returns indexTable
|
||||
every { bsdk.history() } returns emptyFlow()
|
||||
every { tangemPay.history() } returns emptyFlow()
|
||||
every { indexTable.history() } returns emptyFlow()
|
||||
|
||||
// Defaults: not a crypto portfolio, not a payment account, express available.
|
||||
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns none()
|
||||
coEvery { paymentAccountCurrency.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns none()
|
||||
coEvery { txHistoryItemsCountUseCase(any(), any()) } returns 5.right()
|
||||
coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = true, onramp = false).right()
|
||||
}
|
||||
|
||||
// region source resolution
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun resolveOnChainSource(model: ResolutionModel) = runTest {
|
||||
// Arrange
|
||||
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
|
||||
if (model.isCryptoPortfolio) mockk<AccountCryptoCurrencyStatus>().some() else none()
|
||||
model.itemsCount?.let { coEvery { txHistoryItemsCountUseCase(any(), any()) } returns it }
|
||||
coEvery { paymentAccountCurrency.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
|
||||
if (model.isPayment) (mockk<Account.Payment>() to mockk<CryptoCurrencyStatus>()).some() else none()
|
||||
|
||||
// Act
|
||||
val scope = runManager()
|
||||
|
||||
// Assert
|
||||
verify(exactly = if (model.expected == OnChainSource.BSDK) 1 else 0) { bsdkFactory.create(any()) }
|
||||
verify(exactly = if (model.expected == OnChainSource.TangemPay) 1 else 0) { tangemPayFactory.create(any()) }
|
||||
verify(exactly = if (model.expected == OnChainSource.IndexTable) 1 else 0) { indexTableFactory.create(any()) }
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
ResolutionModel(
|
||||
name = "crypto portfolio with tx history",
|
||||
isCryptoPortfolio = true,
|
||||
itemsCount = 5.right(),
|
||||
expected = OnChainSource.BSDK,
|
||||
),
|
||||
ResolutionModel(
|
||||
name = "crypto portfolio with empty tx history",
|
||||
isCryptoPortfolio = true,
|
||||
itemsCount = Either.Left(TxHistoryStateError.EmptyTxHistories),
|
||||
expected = OnChainSource.BSDK,
|
||||
),
|
||||
ResolutionModel(
|
||||
name = "crypto portfolio without tx history support",
|
||||
isCryptoPortfolio = true,
|
||||
itemsCount = Either.Left(TxHistoryStateError.TxHistoryNotImplemented),
|
||||
expected = OnChainSource.IndexTable,
|
||||
),
|
||||
ResolutionModel(
|
||||
name = "payment account",
|
||||
isCryptoPortfolio = false,
|
||||
isPayment = true,
|
||||
expected = OnChainSource.TangemPay,
|
||||
),
|
||||
ResolutionModel(
|
||||
name = "neither crypto nor payment",
|
||||
isCryptoPortfolio = false,
|
||||
isPayment = false,
|
||||
expected = OnChainSource.IndexTable,
|
||||
),
|
||||
)
|
||||
|
||||
// endregion
|
||||
|
||||
// region availability
|
||||
|
||||
@Test
|
||||
fun `GIVEN index-table source and express unavailable WHEN loading THEN Unavailable`() = runTest {
|
||||
// Arrange: no on-chain source and express not available → nothing to show.
|
||||
coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = false, onramp = false).right()
|
||||
|
||||
// Act
|
||||
val states: List<HistoryState>
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
val manager = createManager(scope)
|
||||
states = getEmittedValues(manager.state)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Unavailable)
|
||||
verify(exactly = 0) { indexTableFactory.create(any()) }
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region state sequence
|
||||
|
||||
@Test
|
||||
fun `GIVEN backbone emits content WHEN loading THEN Loading then Content`() = runTest {
|
||||
// Arrange: BSDK source that emits a single content page.
|
||||
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
|
||||
mockk<AccountCryptoCurrencyStatus>().some()
|
||||
every { bsdk.history() } returns flowOf(content())
|
||||
|
||||
// Act
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
val states = getEmittedValues(createManager(scope).state)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(states.first()).isEqualTo(HistoryState.Loading)
|
||||
assertThat(states.last()).isInstanceOf(HistoryState.Content::class.java)
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN load fails WHEN retried from UI THEN Error then Loading then Content`() = runTest {
|
||||
// Arrange: first load throws (DataError), retry succeeds with a content page.
|
||||
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
|
||||
mockk<AccountCryptoCurrencyStatus>().some()
|
||||
coEvery { txHistoryItemsCountUseCase(any(), any()) } returnsMany listOf(
|
||||
Either.Left(TxHistoryStateError.DataError(RuntimeException("boom"))),
|
||||
5.right(),
|
||||
)
|
||||
every { bsdk.history() } returns flowOf(content())
|
||||
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
val manager = createManager(scope)
|
||||
val states = getEmittedValues(manager.state)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act: retry from the UI layer after the full-screen error.
|
||||
manager.reload()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
assertThat(states).containsExactly(
|
||||
HistoryState.Loading,
|
||||
HistoryState.Error,
|
||||
HistoryState.Loading,
|
||||
content(),
|
||||
).inOrder()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN backbone throws unexpectedly WHEN collecting THEN exception reported and pipeline recovers`() = runTest {
|
||||
// Arrange: BSDK source; the history flow throws once, then recovers on the retry.
|
||||
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
|
||||
mockk<AccountCryptoCurrencyStatus>().some()
|
||||
every { bsdk.history() } returnsMany listOf(
|
||||
flow { throw RuntimeException("stream boom") },
|
||||
flowOf(content()),
|
||||
)
|
||||
|
||||
// Act
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
val states = getEmittedValues(createManager(scope).state)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify(exactly = 1) { analyticsExceptionHandler.sendException(any()) }
|
||||
assertThat(states.last()).isInstanceOf(HistoryState.Content::class.java)
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region historySources & action forwarding
|
||||
|
||||
@Test
|
||||
fun `GIVEN successful load WHEN observed THEN historySources emitted`() = runTest {
|
||||
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
|
||||
mockk<AccountCryptoCurrencyStatus>().some()
|
||||
coEvery { expressServiceFetcher.getOrFetch(any(), any()) } returns asset(exchange = true, onramp = false).right()
|
||||
every { bsdk.history() } returns MutableStateFlow(content())
|
||||
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
val sources = getEmittedValues(createManager(scope).historySources)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(sources).containsExactly(
|
||||
HistorySources(onChainSource = OnChainSource.BSDK, isExchangeAvailable = true, isOnrampAvailable = false),
|
||||
)
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded backbone WHEN reload and loadMore THEN forwarded to the backbone`() = runTest {
|
||||
coEvery { getAccountCurrencyStatusUseCase.invokeSync(any<UserWalletId>(), any<CryptoCurrency>()) } returns
|
||||
mockk<AccountCryptoCurrencyStatus>().some()
|
||||
// Keep the backbone open so the forwarding collector stays active.
|
||||
every { bsdk.history() } returns MutableStateFlow(content())
|
||||
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
val manager = createManager(scope)
|
||||
getEmittedValues(manager.state)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act — the actions channel is rendezvous, so let each action be consumed before sending the next.
|
||||
manager.reload()
|
||||
advanceUntilIdle()
|
||||
manager.loadMore()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
verify { bsdk.sendAction(Action.Reload(shouldRefresh = true)) }
|
||||
verify { bsdk.sendAction(Action.LoadMore) }
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun TestScope.runManager(): CoroutineScope {
|
||||
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
|
||||
val manager = createManager(scope)
|
||||
getEmittedValues(manager.state)
|
||||
advanceUntilIdle()
|
||||
return scope
|
||||
}
|
||||
|
||||
private fun TestScope.createManager(modelScope: CoroutineScope) = DefaultHistoryTxListManager(
|
||||
dispatchers = testDispatchers(),
|
||||
expressServiceFetcher = expressServiceFetcher,
|
||||
paymentAccountCurrency = paymentAccountCurrency,
|
||||
getAccountCryptoCurrencyStatusUseCase = getAccountCurrencyStatusUseCase,
|
||||
txHistoryItemsCountUseCase = txHistoryItemsCountUseCase,
|
||||
analyticsExceptionHandler = analyticsExceptionHandler,
|
||||
bsdkOnChainHistoryFactory = bsdkFactory,
|
||||
tangemPayOnChainHistoryFactory = tangemPayFactory,
|
||||
indexTableOnChainHistoryFactory = indexTableFactory,
|
||||
userWalletId = userWalletId,
|
||||
currency = currency,
|
||||
modelScope = modelScope,
|
||||
)
|
||||
|
||||
private fun TestScope.testDispatchers(): CoroutineDispatcherProvider {
|
||||
val dispatcher: CoroutineDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return object : CoroutineDispatcherProvider {
|
||||
override val main = dispatcher
|
||||
override val mainImmediate = dispatcher
|
||||
override val io = dispatcher
|
||||
override val default = dispatcher
|
||||
override val single = dispatcher
|
||||
}
|
||||
}
|
||||
|
||||
private fun content() = HistoryState.Content(items = emptyList(), isLoadingMore = false, hasMore = false)
|
||||
|
||||
private fun asset(exchange: Boolean, onramp: Boolean) = ExpressAsset(
|
||||
id = ExpressAsset.ID(networkId = "eth", contractAddress = "0"),
|
||||
isExchangeAvailable = exchange,
|
||||
isOnrampAvailable = onramp,
|
||||
)
|
||||
|
||||
internal data class ResolutionModel(
|
||||
val name: String,
|
||||
val isCryptoPortfolio: Boolean,
|
||||
val itemsCount: Either<TxHistoryStateError, Int>? = null,
|
||||
val isPayment: Boolean = false,
|
||||
val expected: OnChainSource,
|
||||
) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
package com.tangem.data.txhistory.list
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.express.models.ExchangeTransaction
|
||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.model.OnChainTx
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class TxHistoryInfoMergerTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN express op matched to on-chain WHEN merge THEN enriched single row and on-chain not duplicated`() {
|
||||
// Arrange
|
||||
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
||||
val express = listOf(createSwap(matchHash = "h1", status = ExpressExchangeStatus.Waiting))
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain, express)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
val row = result.single()
|
||||
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
||||
assertThat((row as ExpressTx).txInfo).isInstanceOf(OnChainTx.BSDK::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unmatched active express op WHEN merge THEN standalone live row kept`() {
|
||||
// Arrange
|
||||
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting))
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat((result.single() as ExpressTx).txInfo).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN unmatched terminal express op WHEN merge THEN standalone row kept`() {
|
||||
// Arrange
|
||||
val express = listOf(createSwap(matchHash = "missing", status = ExpressExchangeStatus.Finished))
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain = emptyList(), express = express)
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
val row = result.single()
|
||||
assertThat(row).isInstanceOf(ExpressTx.Swap::class.java)
|
||||
assertThat((row as ExpressTx).txInfo).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN on-chain tx unclaimed by express WHEN merge THEN passed through as OnChain`() {
|
||||
// Arrange
|
||||
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain, express = emptyList())
|
||||
|
||||
// Assert
|
||||
assertThat(result).hasSize(1)
|
||||
assertThat(result.single()).isInstanceOf(OnChainTx.BSDK::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN rows of different timestamps WHEN merge THEN sorted by timestamp descending`() {
|
||||
// Arrange
|
||||
val onChain = listOf(createTxInfo(txHash = "h1", timestamp = 100))
|
||||
val express = listOf(
|
||||
createSwap(matchHash = "missing", createdAtMillis = 200, status = ExpressExchangeStatus.Waiting),
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = mergeTxHistoryInfos(onChain, express)
|
||||
|
||||
// Assert
|
||||
assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder()
|
||||
}
|
||||
|
||||
private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo(
|
||||
txHash = txHash,
|
||||
timestampInMillis = timestamp,
|
||||
isOutgoing = true,
|
||||
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")),
|
||||
sourceType = TxInfo.SourceType.Single("addr"),
|
||||
interactionAddressType = null,
|
||||
status = TxInfo.TransactionStatus.Confirmed,
|
||||
type = TxInfo.TransactionType.Transfer,
|
||||
amount = BigDecimal.ONE,
|
||||
)
|
||||
|
||||
private fun createSwap(
|
||||
matchHash: String?,
|
||||
status: ExpressExchangeStatus,
|
||||
createdAtMillis: Long = 100,
|
||||
isOutgoing: Boolean = true,
|
||||
) = ExpressTx.Swap(
|
||||
tx = ExchangeTransaction(
|
||||
txId = "tx-1",
|
||||
status = status,
|
||||
createdAtMillis = createdAtMillis,
|
||||
provider = null,
|
||||
payinHash = matchHash.takeIf { isOutgoing },
|
||||
payoutHash = matchHash.takeUnless { isOutgoing },
|
||||
fromAddress = null,
|
||||
payoutAddress = null,
|
||||
fromAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||
amount = BigDecimal("1.5"),
|
||||
decimals = 18,
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
|
||||
amount = BigDecimal("0.001"),
|
||||
decimals = 8,
|
||||
),
|
||||
),
|
||||
isOutgoing = isOutgoing,
|
||||
txInfo = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
package com.tangem.data.txhistory.list.chain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.express.models.ExchangeTransaction
|
||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.network.TxInfo
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.model.OnChainTx
|
||||
import com.tangem.domain.txhistory.model.TxHistoryListBatchFlow
|
||||
import com.tangem.domain.txhistory.model.TxHistoryListBatchingContext
|
||||
import com.tangem.domain.txhistory.models.Page
|
||||
import com.tangem.domain.txhistory.models.PaginationWrapper
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.BatchAction
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
import com.tangem.pagination.BatchListState
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Verifies the BSDK backbone: the pagination status maps to [HistoryState], the express overlay is merged in, and
|
||||
* [BsdkOnChainHistory.autoLoadMoreUntilScrollable] dispatches a `LoadMore` while the loaded list is too short to
|
||||
* scroll (fewer than the target) or the last page was empty, and stops otherwise.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class BsdkOnChainHistoryTest {
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "01")
|
||||
private val currency = mockk<CryptoCurrency>(relaxed = true)
|
||||
private val repository = mockk<TxHistoryRepositoryV2>()
|
||||
|
||||
// region state mapping
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial loading WHEN loading THEN Loading`() = runTest {
|
||||
val states = collect(state(items = 0, status = PaginationStatus.InitialLoading))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial loading error WHEN loading THEN Error`() = runTest {
|
||||
val states = collect(state(items = 0, status = PaginationStatus.InitialLoadingError(RuntimeException("boom"))))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty items reaching the end WHEN loading THEN Empty`() = runTest {
|
||||
val states = collect(state(items = 0, status = PaginationStatus.EndOfPagination))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN items and more to load WHEN loading THEN Content with hasMore true`() = runTest {
|
||||
val states = collect(state(items = 25, status = paginating(empty = false)))
|
||||
advanceUntilIdle()
|
||||
|
||||
val last = states.last()
|
||||
assertThat(last).isInstanceOf(HistoryState.Content::class.java)
|
||||
with(last as HistoryState.Content) {
|
||||
assertThat(items).hasSize(25)
|
||||
assertThat(hasMore).isTrue()
|
||||
assertThat(isLoadingMore).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN items and end reached WHEN loading THEN Content with hasMore false`() = runTest {
|
||||
val states = collect(state(items = 25, status = PaginationStatus.EndOfPagination))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat((states.last() as HistoryState.Content).hasMore).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN express op matched to a loaded on-chain tx WHEN loading THEN row is enriched`() = runTest {
|
||||
val states = collect(
|
||||
batchState = state(items = 25, status = PaginationStatus.EndOfPagination, firstTxHash = "match"),
|
||||
express = listOf(createSwap(matchHash = "match")),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val enriched = (states.last() as HistoryState.Content).items.filterIsInstance<ExpressTx.Swap>().single()
|
||||
assertThat(enriched.txInfo).isInstanceOf(OnChainTx.BSDK::class.java)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region auto-load
|
||||
|
||||
@Test
|
||||
fun `GIVEN a short page with more to load WHEN paginating THEN a LoadMore is dispatched`() = runTest {
|
||||
val actions = collectDispatchedActions(state(items = 10, status = paginating(empty = false)))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(actions.filterIsInstance<BatchAction.LoadMore<*>>()).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a page long enough to scroll WHEN paginating THEN no LoadMore is dispatched`() = runTest {
|
||||
val actions = collectDispatchedActions(state(items = 25, status = paginating(empty = false)))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(actions.filterIsInstance<BatchAction.LoadMore<*>>()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the last page came back empty WHEN paginating THEN a LoadMore is dispatched`() = runTest {
|
||||
// Enough items to be scrollable, but the last fetch was empty (a gap) → keep bridging to the end.
|
||||
val actions = collectDispatchedActions(state(items = 25, status = paginating(empty = true)))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(actions.filterIsInstance<BatchAction.LoadMore<*>>()).isNotEmpty()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun TestScope.collect(
|
||||
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
|
||||
express: List<ExpressTx> = emptyList(),
|
||||
): List<HistoryState> {
|
||||
stubRepository(batchState, express)
|
||||
return getEmittedValues(createSut().history())
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the [BatchAction]s the SUT dispatches to the pagination source, to assert the auto-load decision.
|
||||
*
|
||||
* The actions channel is rendezvous, so the source-side collector must be parked before the auto-load check
|
||||
* fires its `LoadMore`: we start collecting the captured `actionsFlow` (and let it park past the initial `Reload`)
|
||||
* before collecting `history()`, which is what triggers the auto-load check.
|
||||
*/
|
||||
private fun TestScope.collectDispatchedActions(
|
||||
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
|
||||
): List<BatchAction<*, *, *>> {
|
||||
val contextSlot = slot<TxHistoryListBatchingContext>()
|
||||
stubRepository(batchState, express = emptyList(), contextSlot = contextSlot)
|
||||
|
||||
val historyFlow = createSut().history() // captures the context synchronously
|
||||
val actions = getEmittedValues(contextSlot.captured.actionsFlow)
|
||||
advanceUntilIdle() // the source-side collector emits the initial Reload, then parks
|
||||
getEmittedValues(historyFlow) // triggers the auto-load check, which may dispatch a LoadMore
|
||||
return actions
|
||||
}
|
||||
|
||||
private fun stubRepository(
|
||||
batchState: BatchListState<Int, PaginationWrapper<TxInfo>>,
|
||||
express: List<ExpressTx>,
|
||||
contextSlot: io.mockk.CapturingSlot<TxHistoryListBatchingContext>? = null,
|
||||
) {
|
||||
val batchFlow = mockk<TxHistoryListBatchFlow> { every { state } returns MutableStateFlow(batchState) }
|
||||
if (contextSlot != null) {
|
||||
every { repository.getTxHistoryBatchFlow(any(), capture(contextSlot)) } returns batchFlow
|
||||
} else {
|
||||
every { repository.getTxHistoryBatchFlow(any(), any()) } returns batchFlow
|
||||
}
|
||||
every { repository.getExpressHistory(any(), any(), any()) } returns flowOf(express)
|
||||
}
|
||||
|
||||
private fun TestScope.createSut() = BsdkOnChainHistory(
|
||||
repository = repository,
|
||||
env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope),
|
||||
)
|
||||
|
||||
private fun state(
|
||||
items: Int,
|
||||
status: PaginationStatus<PaginationWrapper<TxInfo>>,
|
||||
firstTxHash: String? = null,
|
||||
): BatchListState<Int, PaginationWrapper<TxInfo>> {
|
||||
val wrapper = PaginationWrapper(
|
||||
currentPage = Page.Initial,
|
||||
nextPage = Page.LastPage,
|
||||
items = List(items) { index ->
|
||||
val hash = if (index == 0 && firstTxHash != null) firstTxHash else "hash-$index"
|
||||
createTxInfo(txHash = hash)
|
||||
},
|
||||
)
|
||||
return BatchListState(
|
||||
data = if (items == 0) emptyList() else listOf(Batch(key = 0, data = wrapper)),
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
|
||||
private fun paginating(empty: Boolean): PaginationStatus<PaginationWrapper<TxInfo>> {
|
||||
val wrapper = PaginationWrapper<TxInfo>(Page.Initial, Page.LastPage, items = emptyList())
|
||||
return PaginationStatus.Paginating(BatchFetchResult.Success(data = wrapper, empty = empty, last = false))
|
||||
}
|
||||
|
||||
private fun createTxInfo(txHash: String) = TxInfo(
|
||||
txHash = txHash,
|
||||
timestampInMillis = 100,
|
||||
isOutgoing = true,
|
||||
destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User("addr")),
|
||||
sourceType = TxInfo.SourceType.Single("addr"),
|
||||
interactionAddressType = null,
|
||||
status = TxInfo.TransactionStatus.Confirmed,
|
||||
type = TxInfo.TransactionType.Transfer,
|
||||
amount = BigDecimal.ONE,
|
||||
)
|
||||
|
||||
private fun createSwap(matchHash: String) = ExpressTx.Swap(
|
||||
tx = ExchangeTransaction(
|
||||
txId = "tx-1",
|
||||
status = ExpressExchangeStatus.Finished,
|
||||
createdAtMillis = 100,
|
||||
provider = null,
|
||||
payinHash = matchHash,
|
||||
payoutHash = null,
|
||||
fromAddress = null,
|
||||
payoutAddress = null,
|
||||
fromAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||
amount = BigDecimal.ONE,
|
||||
decimals = 18,
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
|
||||
amount = BigDecimal.ONE,
|
||||
decimals = 8,
|
||||
),
|
||||
),
|
||||
isOutgoing = true,
|
||||
txInfo = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.tangem.data.txhistory.list.chain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.express.models.ExchangeTransaction
|
||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.repository.ExpressHistoryPage
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
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
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Verifies the index-backed express backbone (used when a currency has no on-chain history source): it maps an
|
||||
* [ExpressHistoryPage] to [HistoryState] and grows/reset the paging window on `LoadMore` / `Reload`.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class IndexTableOnChainHistoryTest {
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "01")
|
||||
private val currency = mockk<CryptoCurrency>(relaxed = true)
|
||||
private val repository = mockk<TxHistoryRepositoryV2>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(repository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty page WHEN loading THEN Empty`() = runTest {
|
||||
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
|
||||
flowOf(ExpressHistoryPage(items = emptyList(), hasMore = false))
|
||||
|
||||
val states = getEmittedValues(createSut().history())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN non-empty page with more WHEN loading THEN Content with hasMore true`() = runTest {
|
||||
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
|
||||
flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true))
|
||||
|
||||
val states = getEmittedValues(createSut().history())
|
||||
advanceUntilIdle()
|
||||
|
||||
val last = states.last()
|
||||
assertThat(last).isInstanceOf(HistoryState.Content::class.java)
|
||||
with(last as HistoryState.Content) {
|
||||
assertThat(items).hasSize(1)
|
||||
assertThat(hasMore).isTrue()
|
||||
assertThat(isLoadingMore).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded page WHEN loadMore THEN window grows by page size`() = runTest {
|
||||
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
|
||||
flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true))
|
||||
|
||||
val sut = createSut()
|
||||
getEmittedValues(sut.history())
|
||||
advanceUntilIdle()
|
||||
|
||||
sut.sendAction(Action.LoadMore)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE) }
|
||||
verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE * 2) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN grown window WHEN reload THEN window resets to page size`() = runTest {
|
||||
every { repository.getIndexedExpressHistory(any(), any(), any()) } returns
|
||||
flowOf(ExpressHistoryPage(items = listOf(createSwap()), hasMore = true))
|
||||
|
||||
val sut = createSut()
|
||||
getEmittedValues(sut.history())
|
||||
advanceUntilIdle()
|
||||
sut.sendAction(Action.LoadMore)
|
||||
advanceUntilIdle()
|
||||
clearMocks(repository, answers = false)
|
||||
|
||||
sut.sendAction(Action.Reload(shouldRefresh = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { repository.getIndexedExpressHistory(userWalletId, currency, PAGE_SIZE) }
|
||||
}
|
||||
|
||||
private fun TestScope.createSut() = IndexTableOnChainHistory(
|
||||
repository = repository,
|
||||
env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope),
|
||||
)
|
||||
|
||||
private fun createSwap() = ExpressTx.Swap(
|
||||
tx = ExchangeTransaction(
|
||||
txId = "tx-1",
|
||||
status = ExpressExchangeStatus.Waiting,
|
||||
createdAtMillis = 100,
|
||||
provider = null,
|
||||
payinHash = null,
|
||||
payoutHash = null,
|
||||
fromAddress = null,
|
||||
payoutAddress = null,
|
||||
fromAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||
amount = BigDecimal.ONE,
|
||||
decimals = 18,
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
|
||||
amount = BigDecimal.ONE,
|
||||
decimals = 8,
|
||||
),
|
||||
),
|
||||
isOutgoing = true,
|
||||
txInfo = null,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 50
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package com.tangem.data.txhistory.list.chain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.express.models.ExchangeTransaction
|
||||
import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId
|
||||
import com.tangem.domain.express.models.ExpressExchangeStatus
|
||||
import com.tangem.domain.express.models.ExpressTransactionAsset
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.tangempay.model.TangemPayTxHistoryListBatchFlow
|
||||
import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryEnvironment
|
||||
import com.tangem.domain.txhistory.list.HistoryTxListManager.HistoryState
|
||||
import com.tangem.domain.txhistory.model.ExpressTx
|
||||
import com.tangem.domain.txhistory.model.OnChainTx
|
||||
import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2
|
||||
import com.tangem.domain.visa.model.TangemPayTxHistoryItem
|
||||
import com.tangem.pagination.Batch
|
||||
import com.tangem.pagination.BatchFetchResult
|
||||
import com.tangem.pagination.BatchListState
|
||||
import com.tangem.pagination.PaginationStatus
|
||||
import com.tangem.test.core.getEmittedValues
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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
|
||||
|
||||
/**
|
||||
* Verifies the TangemPay backbone: items map to [OnChainTx.TangemPay], the pagination status maps to [HistoryState],
|
||||
* and the express overlay is merged in by hash.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class TangemPayOnChainHistoryTest {
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "01")
|
||||
private val currency = mockk<CryptoCurrency>(relaxed = true)
|
||||
private val repository = mockk<TangemPayTxHistoryRepository>()
|
||||
private val txHistoryRepository = mockk<TxHistoryRepositoryV2>()
|
||||
|
||||
@Test
|
||||
fun `GIVEN loaded items and end WHEN loading THEN Content of TangemPay rows`() = runTest {
|
||||
val payment = createPayment(id = "p1", transactionHash = "h1")
|
||||
val states = collect(
|
||||
batchState = batchListState(items = listOf(payment), status = PaginationStatus.EndOfPagination),
|
||||
express = emptyList(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val last = states.last()
|
||||
assertThat(last).isInstanceOf(HistoryState.Content::class.java)
|
||||
with(last as HistoryState.Content) {
|
||||
assertThat(hasMore).isFalse()
|
||||
assertThat(items.single()).isInstanceOf(OnChainTx.TangemPay::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial loading WHEN loading THEN Loading`() = runTest {
|
||||
val states = collect(
|
||||
batchState = batchListState(items = emptyList(), status = PaginationStatus.InitialLoading),
|
||||
express = emptyList(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN initial loading error WHEN loading THEN Error`() = runTest {
|
||||
val states = collect(
|
||||
batchState = batchListState(
|
||||
items = emptyList(),
|
||||
status = PaginationStatus.InitialLoadingError(throwable = RuntimeException("boom")),
|
||||
),
|
||||
express = emptyList(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN empty items reaching the end WHEN loading THEN Empty`() = runTest {
|
||||
val states = collect(
|
||||
batchState = batchListState(items = emptyList(), status = PaginationStatus.EndOfPagination),
|
||||
express = emptyList(),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(states.last()).isEqualTo(HistoryState.Empty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN express op matching a TangemPay hash WHEN loading THEN row is enriched`() = runTest {
|
||||
val payment = createPayment(id = "p1", transactionHash = "match")
|
||||
val express = listOf(createSwap(matchHash = "match"))
|
||||
val states = collect(
|
||||
batchState = batchListState(items = listOf(payment), status = PaginationStatus.EndOfPagination),
|
||||
express = express,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val items = (states.last() as HistoryState.Content).items
|
||||
val enriched = items.filterIsInstance<ExpressTx.Swap>().single()
|
||||
assertThat(enriched.txInfo).isInstanceOf(OnChainTx.TangemPay::class.java)
|
||||
}
|
||||
|
||||
private fun TestScope.collect(
|
||||
batchState: BatchListState<Int, List<TangemPayTxHistoryItem>>,
|
||||
express: List<ExpressTx>,
|
||||
): List<HistoryState> {
|
||||
val batchFlow = mockk<TangemPayTxHistoryListBatchFlow> {
|
||||
every { state } returns MutableStateFlow(batchState)
|
||||
}
|
||||
every { repository.getTxHistoryBatchFlow(any(), any(), any()) } returns batchFlow
|
||||
every { txHistoryRepository.getExpressHistory(any(), any(), any()) } returns flowOf(express)
|
||||
|
||||
val sut = TangemPayOnChainHistory(
|
||||
repository = repository,
|
||||
txHistoryRepository = txHistoryRepository,
|
||||
env = HistoryEnvironment(userWalletId = userWalletId, currency = currency, modelScope = backgroundScope),
|
||||
)
|
||||
return getEmittedValues(sut.history())
|
||||
}
|
||||
|
||||
private fun batchListState(
|
||||
items: List<TangemPayTxHistoryItem>,
|
||||
status: PaginationStatus<List<TangemPayTxHistoryItem>>,
|
||||
) = BatchListState(
|
||||
data = if (items.isEmpty()) emptyList() else listOf(Batch(key = 0, data = items)),
|
||||
status = status,
|
||||
)
|
||||
|
||||
private fun createPayment(id: String, transactionHash: String) = TangemPayTxHistoryItem.Payment(
|
||||
id = id,
|
||||
jsonRepresentation = "",
|
||||
date = DateTime(100L),
|
||||
amount = BigDecimal.ONE,
|
||||
currency = Currency.getInstance("USD"),
|
||||
transactionHash = transactionHash,
|
||||
)
|
||||
|
||||
private fun createSwap(matchHash: String) = ExpressTx.Swap(
|
||||
tx = ExchangeTransaction(
|
||||
txId = "tx-1",
|
||||
status = ExpressExchangeStatus.Finished,
|
||||
createdAtMillis = 100,
|
||||
provider = null,
|
||||
payinHash = matchHash,
|
||||
payoutHash = null,
|
||||
fromAddress = null,
|
||||
payoutAddress = null,
|
||||
fromAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "eth", contractAddress = "0"),
|
||||
amount = BigDecimal.ONE,
|
||||
decimals = 18,
|
||||
),
|
||||
toAsset = ExpressTransactionAsset(
|
||||
id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"),
|
||||
amount = BigDecimal.ONE,
|
||||
decimals = 8,
|
||||
),
|
||||
),
|
||||
isOutgoing = true,
|
||||
txInfo = null,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue