Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-04 14:14:44 +04:00
parent 7c9112fa70
commit 4390b59231
25 changed files with 1810 additions and 570 deletions

View file

@ -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
}
}

View file

@ -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,
),
)
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
}
}

View file

@ -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"
}
}