Updated on 2026-08-14
This commit is contained in:
parent
7c9112fa70
commit
4390b59231
25 changed files with 1810 additions and 570 deletions
|
|
@ -0,0 +1,103 @@
|
|||
package com.tangem.data.txhistory.repository
|
||||
|
||||
import com.tangem.data.txhistory.repository.converter.toEntity
|
||||
import com.tangem.datasource.api.common.response.getOrThrow
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
|
||||
import com.tangem.datasource.api.onramp.OnrampApi
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
|
||||
import kotlinx.coroutines.flow.first
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Fetches express (exchange & onramp) transaction history from the API and persists it into the local database.
|
||||
*
|
||||
*/
|
||||
internal class ExpressHistoryRepository @Inject constructor(
|
||||
private val exchangeApi: TangemExpressApi,
|
||||
private val onrampApi: OnrampApi,
|
||||
private val expressHistoryDao: ExpressHistoryDao,
|
||||
private val expressSyncStateDao: ExpressSyncStateDao,
|
||||
) {
|
||||
|
||||
suspend fun fetchExchangeHistory(fromAddress: String, limit: Int = DEFAULT_LIMIT): ExchangeHistoryResponse {
|
||||
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
|
||||
|
||||
val response = exchangeApi.getHistory(
|
||||
fromAddress = fromAddress,
|
||||
cursor = state?.afterCursor,
|
||||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveExchanges(ownerAddress = fromAddress, items = response.items)
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun fetchExchangeHistoryDelta(
|
||||
fromAddress: String,
|
||||
limit: Int = DEFAULT_LIMIT,
|
||||
): ExchangeHistoryDeltaResponse {
|
||||
val state = syncState(ExpressSyncStateEntity.Type.EXCHANGE, fromAddress)
|
||||
|
||||
val response = exchangeApi.getHistoryDelta(
|
||||
fromAddress = fromAddress,
|
||||
cursor = state?.deltaCursor,
|
||||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveExchanges(ownerAddress = fromAddress, items = response.items)
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun fetchOnrampHistory(payoutAddress: String, limit: Int = DEFAULT_LIMIT): OnrampHistoryResponse {
|
||||
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
|
||||
|
||||
val response = onrampApi.getHistory(
|
||||
payoutAddress = payoutAddress,
|
||||
afterCursor = state?.afterCursor,
|
||||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveOnramps(ownerAddress = payoutAddress, items = response.items)
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun fetchOnrampHistoryDelta(
|
||||
payoutAddress: String,
|
||||
limit: Int = DEFAULT_LIMIT,
|
||||
): OnrampHistoryDeltaResponse {
|
||||
val state = syncState(ExpressSyncStateEntity.Type.ONRAMP, payoutAddress)
|
||||
|
||||
val response = onrampApi.getHistoryDelta(
|
||||
payoutAddress = payoutAddress,
|
||||
cursor = state?.deltaCursor,
|
||||
limit = limit,
|
||||
).getOrThrow()
|
||||
|
||||
saveOnramps(ownerAddress = payoutAddress, items = response.items)
|
||||
return response
|
||||
}
|
||||
|
||||
suspend fun syncState(type: ExpressSyncStateEntity.Type, address: String): ExpressSyncStateEntity? {
|
||||
return expressSyncStateDao.observe(type = type.name, address = address).first()
|
||||
}
|
||||
|
||||
private suspend fun saveExchanges(ownerAddress: String, items: List<ExchangeItemResponse>) {
|
||||
expressHistoryDao.upsertExchanges(items.map { it.toEntity(ownerAddress) })
|
||||
}
|
||||
|
||||
private suspend fun saveOnramps(ownerAddress: String, items: List<OnrampItemResponse>) {
|
||||
expressHistoryDao.upsertOnramps(items.map { it.toEntity(ownerAddress) })
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_LIMIT = 100
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.tangem.data.txhistory.repository.converter
|
||||
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity
|
||||
|
||||
/**
|
||||
* Maps API history items into their persisted [androidx.room.Entity] representations.
|
||||
*
|
||||
* @param ownerAddress address the history was requested for. Stored as the query key.
|
||||
*/
|
||||
internal fun ExchangeItemResponse.toEntity(ownerAddress: String): ExpressExchangeEntity {
|
||||
return ExpressExchangeEntity(
|
||||
txId = txId,
|
||||
ownerAddress = ownerAddress,
|
||||
providerId = providerId,
|
||||
fromAddress = fromAddress,
|
||||
payinAddress = payinAddress,
|
||||
payinExtraId = payinExtraId,
|
||||
payoutAddress = payoutAddress,
|
||||
refundAddress = refundAddress,
|
||||
refundExtraId = refundExtraId,
|
||||
rateType = rateType,
|
||||
status = status.name,
|
||||
externalTxId = externalTxId,
|
||||
externalTxStatus = externalTxStatus,
|
||||
externalTxUrl = externalTxUrl,
|
||||
payinHash = payinHash,
|
||||
payoutHash = payoutHash,
|
||||
refundNetwork = refundNetwork,
|
||||
refundContractAddress = refundContractAddress,
|
||||
createdAt = createdAt,
|
||||
payTill = payTill,
|
||||
averageDuration = averageDuration,
|
||||
from = ExpressExchangeEntity.AssetEmbedded(
|
||||
contractAddress = fromContractAddress,
|
||||
network = fromNetwork,
|
||||
decimals = fromDecimals,
|
||||
amount = fromAmount,
|
||||
actualAmount = null,
|
||||
),
|
||||
to = ExpressExchangeEntity.AssetEmbedded(
|
||||
contractAddress = toContractAddress,
|
||||
network = toNetwork,
|
||||
decimals = toDecimals,
|
||||
amount = toAmount,
|
||||
actualAmount = toActualAmount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun OnrampItemResponse.toEntity(ownerAddress: String): ExpressOnrampEntity {
|
||||
return ExpressOnrampEntity(
|
||||
txId = txId,
|
||||
ownerAddress = ownerAddress,
|
||||
providerId = providerId,
|
||||
fromAddress = fromAddress,
|
||||
payinAddress = payinAddress,
|
||||
payinExtraId = payinExtraId,
|
||||
payoutAddress = payoutAddress,
|
||||
refundAddress = refundAddress,
|
||||
refundExtraId = refundExtraId,
|
||||
rateType = rateType,
|
||||
status = status.name,
|
||||
externalTxId = externalTxId,
|
||||
externalTxStatus = externalTxStatus,
|
||||
externalTxUrl = externalTxUrl,
|
||||
payinHash = payinHash,
|
||||
payoutHash = payoutHash,
|
||||
refundNetwork = refundNetwork,
|
||||
refundContractAddress = refundContractAddress,
|
||||
createdAt = createdAt,
|
||||
payTill = payTill,
|
||||
averageDuration = averageDuration,
|
||||
from = ExpressOnrampEntity.AssetEmbedded(
|
||||
contractAddress = fromContractAddress,
|
||||
network = fromNetwork,
|
||||
decimals = fromDecimals,
|
||||
amount = fromAmount,
|
||||
actualAmount = null,
|
||||
),
|
||||
to = ExpressOnrampEntity.AssetEmbedded(
|
||||
contractAddress = toContractAddress,
|
||||
network = toNetwork,
|
||||
decimals = toDecimals,
|
||||
amount = toAmount,
|
||||
actualAmount = toActualAmount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package com.tangem.data.txhistory.fetcher
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.account.AccountId
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.tangem.data.txhistory.fetcher
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.tangem.data.txhistory.fetcher
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.common.test.TestAppCoroutineScope
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.account.supplier.SingleAccountListSupplier
|
||||
|
|
|
|||
|
|
@ -0,0 +1,367 @@
|
|||
package com.tangem.data.txhistory.repository
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.data.txhistory.repository.converter.toEntity
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.api.express.TangemExpressApi
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeHistoryResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
|
||||
import com.tangem.datasource.api.express.models.response.ExpressPagination
|
||||
import com.tangem.datasource.api.express.models.response.ExpressPaginationDelta
|
||||
import com.tangem.datasource.api.onramp.OnrampApi
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryDeltaResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampHistoryResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao
|
||||
import com.tangem.datasource.local.txhistory.db.dao.ExpressSyncStateDao
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity
|
||||
import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateEntity
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
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
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class ExpressHistoryRepositoryTest {
|
||||
|
||||
private val exchangeApi: TangemExpressApi = mockk()
|
||||
private val onrampApi: OnrampApi = mockk()
|
||||
private val expressHistoryDao: ExpressHistoryDao = mockk(relaxUnitFun = true)
|
||||
private val expressSyncStateDao: ExpressSyncStateDao = mockk()
|
||||
|
||||
private val repository = ExpressHistoryRepository(
|
||||
exchangeApi = exchangeApi,
|
||||
onrampApi = onrampApi,
|
||||
expressHistoryDao = expressHistoryDao,
|
||||
expressSyncStateDao = expressSyncStateDao,
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(exchangeApi, onrampApi, expressHistoryDao, expressSyncStateDao)
|
||||
}
|
||||
|
||||
// region exchange history
|
||||
|
||||
@Test
|
||||
fun `GIVEN sync state WHEN fetchExchangeHistory THEN passes after cursor and persists items`() = runTest {
|
||||
// GIVEN
|
||||
val item = createExchangeItem()
|
||||
val response = ExchangeHistoryResponse(items = listOf(item), pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
coEvery {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
val result = repository.fetchExchangeHistory(fromAddress = ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(response)
|
||||
coVerify(exactly = 1) {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no sync state WHEN fetchExchangeHistory THEN passes null cursor`() = runTest {
|
||||
// GIVEN
|
||||
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state = null)
|
||||
coEvery {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
repository.fetchExchangeHistory(fromAddress = ADDRESS)
|
||||
|
||||
// THEN
|
||||
coVerify(exactly = 1) {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = null, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN custom limit WHEN fetchExchangeHistory THEN forwards limit to api`() = runTest {
|
||||
// GIVEN
|
||||
val response = ExchangeHistoryResponse(items = emptyList(), pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
coEvery {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
repository.fetchExchangeHistory(fromAddress = ADDRESS, limit = 25)
|
||||
|
||||
// THEN
|
||||
coVerify(exactly = 1) {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = 25)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api error WHEN fetchExchangeHistory THEN throws and does not persist`() = runTest {
|
||||
// GIVEN
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
val error = httpError()
|
||||
coEvery {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Error(error).cast()
|
||||
|
||||
// WHEN
|
||||
val thrown = runCatching { repository.fetchExchangeHistory(fromAddress = ADDRESS) }.exceptionOrNull()
|
||||
|
||||
// THEN
|
||||
assertThat(thrown).isEqualTo(error)
|
||||
coVerify(exactly = 0) { expressHistoryDao.upsertExchanges(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN sync state WHEN fetchExchangeHistoryDelta THEN passes delta cursor and persists items`() = runTest {
|
||||
// GIVEN
|
||||
val item = createExchangeItem()
|
||||
val response = ExchangeHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
|
||||
coEvery {
|
||||
exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
val result = repository.fetchExchangeHistoryDelta(fromAddress = ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(response)
|
||||
coVerify(exactly = 1) {
|
||||
exchangeApi.getHistoryDelta(fromAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertExchanges(listOf(item.toEntity(ADDRESS))) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region onramp history
|
||||
|
||||
@Test
|
||||
fun `GIVEN sync state WHEN fetchOnrampHistory THEN passes after cursor and persists items`() = runTest {
|
||||
// GIVEN
|
||||
val item = createOnrampItem()
|
||||
val response = OnrampHistoryResponse(items = listOf(item), pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
coEvery {
|
||||
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
val result = repository.fetchOnrampHistory(payoutAddress = ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(response)
|
||||
coVerify(exactly = 1) {
|
||||
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no sync state WHEN fetchOnrampHistory THEN passes null cursor`() = runTest {
|
||||
// GIVEN
|
||||
val response = OnrampHistoryResponse(items = emptyList(), pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, state = null)
|
||||
coEvery {
|
||||
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
repository.fetchOnrampHistory(payoutAddress = ADDRESS)
|
||||
|
||||
// THEN
|
||||
coVerify(exactly = 1) {
|
||||
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = null, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN sync state WHEN fetchOnrampHistoryDelta THEN passes delta cursor and persists items`() = runTest {
|
||||
// GIVEN
|
||||
val item = createOnrampItem()
|
||||
val response = OnrampHistoryDeltaResponse(items = listOf(item), pagination = paginationDelta())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(deltaCursor = DELTA_CURSOR))
|
||||
coEvery {
|
||||
onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
|
||||
// WHEN
|
||||
val result = repository.fetchOnrampHistoryDelta(payoutAddress = ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(response)
|
||||
coVerify(exactly = 1) {
|
||||
onrampApi.getHistoryDelta(payoutAddress = ADDRESS, cursor = DELTA_CURSOR, limit = DEFAULT_LIMIT)
|
||||
}
|
||||
coVerify(exactly = 1) { expressHistoryDao.upsertOnramps(listOf(item.toEntity(ADDRESS))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN api error WHEN fetchOnrampHistory THEN throws and does not persist`() = runTest {
|
||||
// GIVEN
|
||||
stubSyncState(ExpressSyncStateEntity.Type.ONRAMP, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
val error = httpError()
|
||||
coEvery {
|
||||
onrampApi.getHistory(payoutAddress = ADDRESS, afterCursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Error(error).cast()
|
||||
|
||||
// WHEN
|
||||
val thrown = runCatching { repository.fetchOnrampHistory(payoutAddress = ADDRESS) }.exceptionOrNull()
|
||||
|
||||
// THEN
|
||||
assertThat(thrown).isEqualTo(error)
|
||||
coVerify(exactly = 0) { expressHistoryDao.upsertOnramps(any()) }
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region syncState
|
||||
|
||||
@Test
|
||||
fun `GIVEN stored sync state WHEN syncState THEN returns first emitted value`() = runTest {
|
||||
// GIVEN
|
||||
val state = syncState(afterCursor = AFTER_CURSOR, deltaCursor = DELTA_CURSOR)
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, state)
|
||||
|
||||
// WHEN
|
||||
val result = repository.syncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(result).isEqualTo(state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple items WHEN fetchExchangeHistory THEN maps every item with owner address`() = runTest {
|
||||
// GIVEN
|
||||
val items = listOf(
|
||||
createExchangeItem(txId = "tx-1"),
|
||||
createExchangeItem(txId = "tx-2"),
|
||||
)
|
||||
val response = ExchangeHistoryResponse(items = items, pagination = pagination())
|
||||
stubSyncState(ExpressSyncStateEntity.Type.EXCHANGE, ADDRESS, syncState(afterCursor = AFTER_CURSOR))
|
||||
coEvery {
|
||||
exchangeApi.getHistory(fromAddress = ADDRESS, cursor = AFTER_CURSOR, limit = any())
|
||||
} returns ApiResponse.Success(response)
|
||||
val saved = slot<List<ExpressExchangeEntity>>()
|
||||
coEvery { expressHistoryDao.upsertExchanges(capture(saved)) } returns Unit
|
||||
|
||||
// WHEN
|
||||
repository.fetchExchangeHistory(fromAddress = ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(saved.captured).isEqualTo(items.map { it.toEntity(ADDRESS) })
|
||||
assertThat(saved.captured.map { it.ownerAddress }.toSet()).containsExactly(ADDRESS)
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
private fun stubSyncState(type: ExpressSyncStateEntity.Type, address: String, state: ExpressSyncStateEntity?) {
|
||||
coEvery { expressSyncStateDao.observe(type = type.name, address = address) } returns flowOf(state)
|
||||
}
|
||||
|
||||
private fun syncState(afterCursor: String? = null, deltaCursor: String? = null) = ExpressSyncStateEntity(
|
||||
type = ExpressSyncStateEntity.Type.EXCHANGE.name,
|
||||
address = ADDRESS,
|
||||
isInitialCompleted = true,
|
||||
afterCursor = afterCursor,
|
||||
deltaCursor = deltaCursor,
|
||||
)
|
||||
|
||||
private fun pagination() = ExpressPagination(endCursor = "end", startDeltaCursor = "delta", hasMore = false)
|
||||
|
||||
private fun paginationDelta() = ExpressPaginationDelta(startCursor = "start", hasMore = false)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T : Any> ApiResponse.Error.cast(): ApiResponse<T> = this as ApiResponse<T>
|
||||
|
||||
private fun httpError() = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.INTERNAL_SERVER_ERROR,
|
||||
message = "boom",
|
||||
errorBody = null,
|
||||
)
|
||||
|
||||
private fun createExchangeItem(txId: String = "exchange-tx-1") = ExchangeItemResponse(
|
||||
txId = txId,
|
||||
providerId = "changelly",
|
||||
fromAddress = "0xfrom",
|
||||
payinAddress = "0xpayin",
|
||||
payinExtraId = null,
|
||||
payoutAddress = "0xpayout",
|
||||
refundAddress = null,
|
||||
refundExtraId = null,
|
||||
rateType = "float",
|
||||
status = ExchangeItemResponse.Status.FINISHED,
|
||||
externalTxId = null,
|
||||
externalTxStatus = null,
|
||||
externalTxUrl = null,
|
||||
payinHash = "payin-hash",
|
||||
payoutHash = "payout-hash",
|
||||
refundNetwork = null,
|
||||
refundContractAddress = null,
|
||||
createdAt = "2026-06-01T00:00:00Z",
|
||||
payTill = null,
|
||||
averageDuration = null,
|
||||
fromContractAddress = "0xfromContract",
|
||||
fromNetwork = "ethereum",
|
||||
fromDecimals = 18,
|
||||
fromAmount = "1.0",
|
||||
toContractAddress = "0xtoContract",
|
||||
toNetwork = "bitcoin",
|
||||
toDecimals = 8,
|
||||
toAmount = "1.0",
|
||||
toActualAmount = "0.99",
|
||||
)
|
||||
|
||||
private fun createOnrampItem(txId: String = "onramp-tx-1") = OnrampItemResponse(
|
||||
txId = txId,
|
||||
providerId = "mercuryo",
|
||||
fromAddress = "0xfrom",
|
||||
payinAddress = "0xpayin",
|
||||
payinExtraId = null,
|
||||
payoutAddress = "0xpayout",
|
||||
refundAddress = null,
|
||||
refundExtraId = null,
|
||||
rateType = "fixed",
|
||||
status = OnrampItemResponse.Status.FINISHED,
|
||||
externalTxId = null,
|
||||
externalTxStatus = null,
|
||||
externalTxUrl = null,
|
||||
payinHash = "payin-hash",
|
||||
payoutHash = "payout-hash",
|
||||
refundNetwork = null,
|
||||
refundContractAddress = null,
|
||||
createdAt = "2026-06-01T00:00:00Z",
|
||||
payTill = null,
|
||||
averageDuration = null,
|
||||
fromContractAddress = "0xfromContract",
|
||||
fromNetwork = "usd",
|
||||
fromDecimals = 2,
|
||||
fromAmount = "100.0",
|
||||
toContractAddress = "0xtoContract",
|
||||
toNetwork = "bitcoin",
|
||||
toDecimals = 8,
|
||||
toAmount = "0.001",
|
||||
toActualAmount = "0.99",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ADDRESS = "0xowner"
|
||||
const val AFTER_CURSOR = "after-cursor"
|
||||
const val DELTA_CURSOR = "delta-cursor"
|
||||
const val DEFAULT_LIMIT = 100
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
package com.tangem.data.txhistory.repository.converter
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.datasource.api.express.models.response.ExchangeItemResponse
|
||||
import com.tangem.datasource.api.onramp.models.response.OnrampItemResponse
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class ExpressHistoryConverterTest {
|
||||
|
||||
@Test
|
||||
fun `GIVEN exchange item WHEN toEntity THEN all transaction fields are mapped`() {
|
||||
// GIVEN
|
||||
val item = createExchangeItem()
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.txId).isEqualTo(item.txId)
|
||||
assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
|
||||
assertThat(entity.providerId).isEqualTo(item.providerId)
|
||||
assertThat(entity.fromAddress).isEqualTo(item.fromAddress)
|
||||
assertThat(entity.payinAddress).isEqualTo(item.payinAddress)
|
||||
assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId)
|
||||
assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
|
||||
assertThat(entity.refundAddress).isEqualTo(item.refundAddress)
|
||||
assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId)
|
||||
assertThat(entity.rateType).isEqualTo(item.rateType)
|
||||
assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
|
||||
assertThat(entity.externalTxStatus).isEqualTo(item.externalTxStatus)
|
||||
assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
|
||||
assertThat(entity.payinHash).isEqualTo(item.payinHash)
|
||||
assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
|
||||
assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
|
||||
assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
|
||||
assertThat(entity.createdAt).isEqualTo(item.createdAt)
|
||||
assertThat(entity.payTill).isEqualTo(item.payTill)
|
||||
assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN exchange item WHEN toEntity THEN status is stored as enum name`() {
|
||||
// GIVEN
|
||||
val item = createExchangeItem(status = ExchangeItemResponse.Status.FINISHED)
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.status).isEqualTo("FINISHED")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN exchange item WHEN toEntity THEN from and to assets are mapped`() {
|
||||
// GIVEN
|
||||
val item = createExchangeItem()
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
|
||||
assertThat(entity.from.network).isEqualTo(item.fromNetwork)
|
||||
assertThat(entity.from.decimals).isEqualTo(item.fromDecimals)
|
||||
assertThat(entity.from.amount).isEqualTo(item.fromAmount)
|
||||
// `from` asset never carries an actual amount
|
||||
assertThat(entity.from.actualAmount).isNull()
|
||||
|
||||
assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
|
||||
assertThat(entity.to.network).isEqualTo(item.toNetwork)
|
||||
assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
|
||||
assertThat(entity.to.amount).isEqualTo(item.toAmount)
|
||||
assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN exchange item with null optional fields WHEN toEntity THEN nulls are preserved`() {
|
||||
// GIVEN
|
||||
val item = createExchangeItem(
|
||||
payinExtraId = null,
|
||||
refundAddress = null,
|
||||
refundExtraId = null,
|
||||
externalTxId = null,
|
||||
externalTxStatus = null,
|
||||
externalTxUrl = null,
|
||||
payinHash = null,
|
||||
payoutHash = null,
|
||||
refundNetwork = null,
|
||||
refundContractAddress = null,
|
||||
payTill = null,
|
||||
averageDuration = null,
|
||||
toActualAmount = null,
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.payinExtraId).isNull()
|
||||
assertThat(entity.refundAddress).isNull()
|
||||
assertThat(entity.refundExtraId).isNull()
|
||||
assertThat(entity.externalTxId).isNull()
|
||||
assertThat(entity.externalTxStatus).isNull()
|
||||
assertThat(entity.externalTxUrl).isNull()
|
||||
assertThat(entity.payinHash).isNull()
|
||||
assertThat(entity.payoutHash).isNull()
|
||||
assertThat(entity.refundNetwork).isNull()
|
||||
assertThat(entity.refundContractAddress).isNull()
|
||||
assertThat(entity.payTill).isNull()
|
||||
assertThat(entity.averageDuration).isNull()
|
||||
assertThat(entity.to.actualAmount).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN onramp item WHEN toEntity THEN all transaction fields are mapped`() {
|
||||
// GIVEN
|
||||
val item = createOnrampItem()
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.txId).isEqualTo(item.txId)
|
||||
assertThat(entity.ownerAddress).isEqualTo(OWNER_ADDRESS)
|
||||
assertThat(entity.providerId).isEqualTo(item.providerId)
|
||||
assertThat(entity.fromAddress).isEqualTo(item.fromAddress)
|
||||
assertThat(entity.payinAddress).isEqualTo(item.payinAddress)
|
||||
assertThat(entity.payinExtraId).isEqualTo(item.payinExtraId)
|
||||
assertThat(entity.payoutAddress).isEqualTo(item.payoutAddress)
|
||||
assertThat(entity.refundAddress).isEqualTo(item.refundAddress)
|
||||
assertThat(entity.refundExtraId).isEqualTo(item.refundExtraId)
|
||||
assertThat(entity.rateType).isEqualTo(item.rateType)
|
||||
assertThat(entity.externalTxId).isEqualTo(item.externalTxId)
|
||||
assertThat(entity.externalTxStatus).isEqualTo(item.externalTxStatus)
|
||||
assertThat(entity.externalTxUrl).isEqualTo(item.externalTxUrl)
|
||||
assertThat(entity.payinHash).isEqualTo(item.payinHash)
|
||||
assertThat(entity.payoutHash).isEqualTo(item.payoutHash)
|
||||
assertThat(entity.refundNetwork).isEqualTo(item.refundNetwork)
|
||||
assertThat(entity.refundContractAddress).isEqualTo(item.refundContractAddress)
|
||||
assertThat(entity.createdAt).isEqualTo(item.createdAt)
|
||||
assertThat(entity.payTill).isEqualTo(item.payTill)
|
||||
assertThat(entity.averageDuration).isEqualTo(item.averageDuration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN onramp item WHEN toEntity THEN status is stored as enum name`() {
|
||||
// GIVEN
|
||||
val item = createOnrampItem(status = OnrampItemResponse.Status.WAITING_TX_HASH)
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.status).isEqualTo("WAITING_TX_HASH")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN onramp item WHEN toEntity THEN from and to assets are mapped`() {
|
||||
// GIVEN
|
||||
val item = createOnrampItem()
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.from.contractAddress).isEqualTo(item.fromContractAddress)
|
||||
assertThat(entity.from.network).isEqualTo(item.fromNetwork)
|
||||
assertThat(entity.from.decimals).isEqualTo(item.fromDecimals)
|
||||
assertThat(entity.from.amount).isEqualTo(item.fromAmount)
|
||||
assertThat(entity.from.actualAmount).isNull()
|
||||
|
||||
assertThat(entity.to.contractAddress).isEqualTo(item.toContractAddress)
|
||||
assertThat(entity.to.network).isEqualTo(item.toNetwork)
|
||||
assertThat(entity.to.decimals).isEqualTo(item.toDecimals)
|
||||
assertThat(entity.to.amount).isEqualTo(item.toAmount)
|
||||
assertThat(entity.to.actualAmount).isEqualTo(item.toActualAmount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN onramp item with null optional fields WHEN toEntity THEN nulls are preserved`() {
|
||||
// GIVEN
|
||||
val item = createOnrampItem(
|
||||
payinExtraId = null,
|
||||
refundAddress = null,
|
||||
refundExtraId = null,
|
||||
externalTxId = null,
|
||||
externalTxStatus = null,
|
||||
externalTxUrl = null,
|
||||
payinHash = null,
|
||||
payoutHash = null,
|
||||
refundNetwork = null,
|
||||
refundContractAddress = null,
|
||||
payTill = null,
|
||||
averageDuration = null,
|
||||
toActualAmount = null,
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val entity = item.toEntity(ownerAddress = OWNER_ADDRESS)
|
||||
|
||||
// THEN
|
||||
assertThat(entity.payinExtraId).isNull()
|
||||
assertThat(entity.refundAddress).isNull()
|
||||
assertThat(entity.refundExtraId).isNull()
|
||||
assertThat(entity.externalTxId).isNull()
|
||||
assertThat(entity.externalTxStatus).isNull()
|
||||
assertThat(entity.externalTxUrl).isNull()
|
||||
assertThat(entity.payinHash).isNull()
|
||||
assertThat(entity.payoutHash).isNull()
|
||||
assertThat(entity.refundNetwork).isNull()
|
||||
assertThat(entity.refundContractAddress).isNull()
|
||||
assertThat(entity.payTill).isNull()
|
||||
assertThat(entity.averageDuration).isNull()
|
||||
assertThat(entity.to.actualAmount).isNull()
|
||||
}
|
||||
|
||||
private fun createExchangeItem(
|
||||
status: ExchangeItemResponse.Status = ExchangeItemResponse.Status.WAITING,
|
||||
payinExtraId: String? = "payin-extra",
|
||||
refundAddress: String? = "refund-address",
|
||||
refundExtraId: String? = "refund-extra",
|
||||
externalTxId: String? = "external-tx-id",
|
||||
externalTxStatus: String? = "external-status",
|
||||
externalTxUrl: String? = "https://provider.example/tx",
|
||||
payinHash: String? = "payin-hash",
|
||||
payoutHash: String? = "payout-hash",
|
||||
refundNetwork: String? = "ethereum",
|
||||
refundContractAddress: String? = "0xrefund",
|
||||
payTill: String? = "2026-06-01T00:10:00Z",
|
||||
averageDuration: Long? = 600L,
|
||||
toActualAmount: String? = "0.99",
|
||||
) = ExchangeItemResponse(
|
||||
txId = "exchange-tx-1",
|
||||
providerId = "changelly",
|
||||
fromAddress = "0xfrom",
|
||||
payinAddress = "0xpayin",
|
||||
payinExtraId = payinExtraId,
|
||||
payoutAddress = "0xpayout",
|
||||
refundAddress = refundAddress,
|
||||
refundExtraId = refundExtraId,
|
||||
rateType = "float",
|
||||
status = status,
|
||||
externalTxId = externalTxId,
|
||||
externalTxStatus = externalTxStatus,
|
||||
externalTxUrl = externalTxUrl,
|
||||
payinHash = payinHash,
|
||||
payoutHash = payoutHash,
|
||||
refundNetwork = refundNetwork,
|
||||
refundContractAddress = refundContractAddress,
|
||||
createdAt = "2026-06-01T00:00:00Z",
|
||||
payTill = payTill,
|
||||
averageDuration = averageDuration,
|
||||
fromContractAddress = "0xfromContract",
|
||||
fromNetwork = "ethereum",
|
||||
fromDecimals = 18,
|
||||
fromAmount = "1.0",
|
||||
toContractAddress = "0xtoContract",
|
||||
toNetwork = "bitcoin",
|
||||
toDecimals = 8,
|
||||
toAmount = "1.0",
|
||||
toActualAmount = toActualAmount,
|
||||
)
|
||||
|
||||
private fun createOnrampItem(
|
||||
status: OnrampItemResponse.Status = OnrampItemResponse.Status.WAITING,
|
||||
payinExtraId: String? = "payin-extra",
|
||||
refundAddress: String? = "refund-address",
|
||||
refundExtraId: String? = "refund-extra",
|
||||
externalTxId: String? = "external-tx-id",
|
||||
externalTxStatus: String? = "external-status",
|
||||
externalTxUrl: String? = "https://provider.example/tx",
|
||||
payinHash: String? = "payin-hash",
|
||||
payoutHash: String? = "payout-hash",
|
||||
refundNetwork: String? = "ethereum",
|
||||
refundContractAddress: String? = "0xrefund",
|
||||
payTill: String? = "2026-06-01T00:10:00Z",
|
||||
averageDuration: Long? = 600L,
|
||||
toActualAmount: String? = "0.99",
|
||||
) = OnrampItemResponse(
|
||||
txId = "onramp-tx-1",
|
||||
providerId = "mercuryo",
|
||||
fromAddress = "0xfrom",
|
||||
payinAddress = "0xpayin",
|
||||
payinExtraId = payinExtraId,
|
||||
payoutAddress = "0xpayout",
|
||||
refundAddress = refundAddress,
|
||||
refundExtraId = refundExtraId,
|
||||
rateType = "fixed",
|
||||
status = status,
|
||||
externalTxId = externalTxId,
|
||||
externalTxStatus = externalTxStatus,
|
||||
externalTxUrl = externalTxUrl,
|
||||
payinHash = payinHash,
|
||||
payoutHash = payoutHash,
|
||||
refundNetwork = refundNetwork,
|
||||
refundContractAddress = refundContractAddress,
|
||||
createdAt = "2026-06-01T00:00:00Z",
|
||||
payTill = payTill,
|
||||
averageDuration = averageDuration,
|
||||
fromContractAddress = "0xfromContract",
|
||||
fromNetwork = "usd",
|
||||
fromDecimals = 2,
|
||||
fromAmount = "100.0",
|
||||
toContractAddress = "0xtoContract",
|
||||
toNetwork = "bitcoin",
|
||||
toDecimals = 8,
|
||||
toAmount = "0.001",
|
||||
toActualAmount = toActualAmount,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val OWNER_ADDRESS = "0xowner"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue