Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ kotlin {
}

commonTest.dependencies {
implementation(libs.ktor.client.mock)
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.koin.test)
Expand Down Expand Up @@ -231,4 +232,4 @@ tasks.configureEach {
tasks.getByName("clean").doFirst {
delete(project.file("src/include"))
delete(project.file("src/libs"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import com.blockstream.data.btcpricehistory.model.NetworkBitcoinPriceData
import com.blockstream.data.btcpricehistory.model.timeAgoInMillis
import kotlin.time.Clock

fun NetworkBitcoinPriceData.asChartData(): BitcoinChartData {
fun NetworkBitcoinPriceData.asChartData(): BitcoinChartData? {
val data = this
val prices = mutableMapOf<BitcoinChartPeriod, List<Pair<Long, Float>>>() //timestamp, price

val dailyPrices = data.dailyPrices.mapAndSortNotNullPrices()
val currentPrice = dailyPrices.lastOrNull()?.second ?: return null
val monthlyPrices = data.monthlyPrices.mapAndSortNotNullPrices()
val fullPrices = data.fullPrices.mapAndSortNotNullPrices()

Expand All @@ -21,7 +22,6 @@ fun NetworkBitcoinPriceData.asChartData(): BitcoinChartData {
prices[BitcoinChartPeriod.ONE_YEAR] = fullPrices.filter { it.first >= BitcoinChartPeriod.ONE_YEAR.timeAgoInMillis() }
prices[BitcoinChartPeriod.FIVE_YEAR] = fullPrices.filter { it.first >= BitcoinChartPeriod.FIVE_YEAR.timeAgoInMillis() }

val currentPrice = dailyPrices.last().second
val lastRefreshedAt = Clock.System.now().toEpochMilliseconds()

return BitcoinChartData(
Expand All @@ -43,4 +43,4 @@ private fun List<List<Double?>>.mapAndSortNotNullPrices(): List<Pair<Long, Float
null
}
}.sortedBy { it.first }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ data class NetworkBitcoinPriceData(

@SerialName("prices_day")
val dailyPrices: List<List<Double?>> = emptyList()
get() = field.sortedBy { it[0] }
get() = field.sortedBy { it.getOrNull(0) }

@SerialName("prices_full")
val fullPrices: List<List<Double?>> = emptyList()
get() = field.sortedBy { it[0] }
get() = field.sortedBy { it.getOrNull(0) }

@SerialName("prices_month")
val monthlyPrices: List<List<Double?>> = emptyList()
get() = field.sortedBy { it[0] }
get() = field.sortedBy { it.getOrNull(0) }
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ package com.blockstream.data.meld

import com.blockstream.utils.Loggable
import com.blockstream.network.AppHttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.defaultRequest
import io.ktor.http.ContentType
import io.ktor.http.contentType

class MeldHttpClient(appInfo: com.blockstream.data.config.AppInfo) : AppHttpClient(appInfo.isDevelopmentOrDebug, {
class MeldHttpClient(
appInfo: com.blockstream.data.config.AppInfo,
engine: HttpClientEngine? = null,
) : AppHttpClient(appInfo.isDevelopmentOrDebug, {
install(HttpTimeout) {
this.requestTimeoutMillis = 60_000
this.connectTimeoutMillis = 30_000
Expand All @@ -18,9 +22,9 @@ class MeldHttpClient(appInfo: com.blockstream.data.config.AppInfo) : AppHttpClie
?: MELD_SANDBOX)
contentType(ContentType.Application.Json)
}
}) {
}, engine) {
companion object : Loggable() {
private const val MELD_PRODUCTION = "https://ramps.blockstream.com"
private const val MELD_SANDBOX = "https://ramps.sandbox.blockstream.com"
}
}
}
148 changes: 102 additions & 46 deletions data/src/commonTest/kotlin/MeldRepositoryTest.kt
Original file line number Diff line number Diff line change
@@ -1,73 +1,129 @@
package com.blockstream.green.data.meld

import com.blockstream.data.config.AppInfo
import com.blockstream.data.dataModule
import com.blockstream.data.meld.MeldHttpClient
import com.blockstream.data.meld.MeldRepository
import com.blockstream.data.meld.data.CryptoQuoteRequest
import com.blockstream.data.meld.datasource.MeldLocalDataSource
import com.blockstream.data.meld.datasource.MeldRemoteDataSource
import com.blockstream.network.NetworkResponse
import com.blockstream.network.dataOrThrow
import com.blockstream.utils.Loggable
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.toByteArray
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.inject
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class MeldRepositoryTest : KoinTest {

companion object : Loggable()

private val meldRepository: com.blockstream.data.meld.MeldRepository by inject()

@BeforeTest
fun beforeTest() {
startKoin {
modules(
module {
single {
AppInfo(userAgent = "test", "0.0.0", isDebug = false, isDevelopment = false, isTest = true)
}
},
dataModule
/** Exercises the real HTTP client with an in-memory transport, without creating live sessions. */
class MeldRepositoryTest {
@Test
fun `Request CryptoQuoteRequest`() = runTest {
val engine = MockEngine { request ->
assertEquals(HttpMethod.Post, request.method)
assertEquals("https://ramps.blockstream.com/payments/crypto/quote", request.url.toString())
assertEquals("application/json", request.body.contentType?.toString())
assertEquals(
Json.parseToJsonElement("""{"countryCode":"US","sourceAmount":"200","sourceCurrencyCode":"USD","destinationCurrencyCode":"BTC"}"""),
Json.parseToJsonElement(request.body.toByteArray().decodeToString()),
)
respond(quoteResponse, HttpStatusCode.OK, jsonHeaders)
}
withRepository(engine) { repository ->
val quote = repository.createCryptoQuote(CryptoQuoteRequest()).dataOrThrow().quotes!!.single()
assertEquals("BTC", quote.destinationCurrencyCode)
assertEquals("0.002", quote.destinationAmount)
assertEquals("TEST_PROVIDER", quote.serviceProvider)
}
assertEquals(1, engine.requestHistory.size)
}

@AfterTest
fun afterTest() {
stopKoin()
@Test
fun `Request CryptoWidgetRequest`() = runTest {
val engine = MockEngine { request ->
assertEquals(HttpMethod.Post, request.method)
assertEquals("ramps.blockstream.com", request.url.host)
when (request.url.encodedPath) {
"/payments/crypto/quote" -> respond(quoteResponse, HttpStatusCode.OK, jsonHeaders)
"/crypto/session/widget" -> {
assertEquals(
Json.parseToJsonElement("""{"sessionType":"BUY","externalCustomerId":"test-customer","sessionData":{"countryCode":"US","sourceAmount":"200","sourceCurrencyCode":"USD","destinationCurrencyCode":"BTC","walletAddress":"test-wallet-address","serviceProvider":"TEST_PROVIDER","redirectUrl":"https://green-webhooks.blockstream.com/thank-you"}}"""),
Json.parseToJsonElement(request.body.toByteArray().decodeToString()),
)
respond(
"""{"id":"test-session","customerId":"test-customer","widgetUrl":"https://example.test/widget","token":"test-token"}""",
HttpStatusCode.OK, jsonHeaders,
)
}
else -> error("Unexpected request: ${request.url}")
}
}
withRepository(engine) { repository ->
val request = repository.createCryptoQuote(CryptoQuoteRequest()).dataOrThrow().quotes!!.single()
.toCryptoWidgetRequest("test-wallet-address", "test-customer")
val widget = repository.createCryptoWidget(request).dataOrThrow()
assertEquals("test-session", widget.id)
assertEquals("https://example.test/widget", widget.widgetUrl)
}
assertEquals(2, engine.requestHistory.size)
}

@Test
fun `Request CryptoQuoteRequest`() = runTest {
meldRepository.createCryptoQuote(CryptoQuoteRequest()).also {
logger.d { "$it" }
assertNotNull(it.dataOrThrow().quotes)
fun `Request CryptoLimitsRequest`() = runTest {
val engine = MockEngine { request ->
assertEquals(HttpMethod.Get, request.method)
assertEquals("/payments/crypto/limits", request.url.encodedPath)
assertEquals("EUR", request.url.parameters["fiatCurrency"])
respond(
"""[{"currencyCode":"EUR","defaultAmount":200,"minAmount":20,"maxAmount":1000}]""",
HttpStatusCode.OK, jsonHeaders,
)
}
withRepository(engine) { repository ->
val limit = repository.getCryptoLimits("EUR").dataOrThrow().single()
assertEquals("EUR", limit.currencyCode)
assertEquals(20.0, limit.minAmount)
assertEquals(1000.0, limit.maxAmount)
}
assertEquals(1, engine.requestHistory.size)
}

@Test
fun `Request CryptoWidgetRequest`() = runTest {
meldRepository.createCryptoQuote(CryptoQuoteRequest())
.dataOrThrow().quotes!!.first().let {
it.toCryptoWidgetRequest("bc1qcr8ktl3nzwh8xm88225ysynt5zsdydae26thrg")
}.also {
meldRepository.createCryptoWidget(it).also {
logger.d { "$it" }
assertNotNull(it.dataOrThrow().widgetUrl)
}
fun `Forbidden response remains an error`() = runTest {
val engine = MockEngine {
respond("""{"message":"Forbidden"}""", HttpStatusCode.Forbidden, jsonHeaders)
}
withRepository(engine) { repository ->
assertEquals(NetworkResponse.Error(403, "Forbidden"), repository.getCryptoLimits("EUR"))
}
}

@Test
fun `Request CryptoLimitsRequest`() = runTest {
meldRepository.getCryptoLimits(fiatCurrency = "EUR").also {
assertNotEquals(0.0, it.dataOrThrow().first().maxAmount)
fun `Malformed successful response remains an error`() = runTest {
val engine = MockEngine { respond("not json", HttpStatusCode.OK, jsonHeaders) }
withRepository(engine) { repository ->
assertTrue(repository.getCryptoLimits("EUR") is NetworkResponse.Error)
}
}

private suspend fun withRepository(engine: MockEngine, block: suspend (MeldRepository) -> Unit) {
val client = MeldHttpClient(AppInfo("test", "0.0.0", isDebug = false, isDevelopment = false, isTest = true), engine)
try {
block(MeldRepository(MeldRemoteDataSource(client), MeldLocalDataSource()))
} finally {
client.httpClient.close()
engine.close()
}
}

companion object {
private val jsonHeaders = headersOf(HttpHeaders.ContentType, "application/json")
private const val quoteResponse = """{"quotes":[{"transactionType":"BUY","sourceAmount":"200","sourceAmountWithoutFees":"190","fiatAmountWithoutFees":"190","sourceCurrencyCode":"USD","countryCode":"US","totalFee":"10","transactionFee":"10","destinationAmount":"0.002","destinationCurrencyCode":"BTC","exchangeRate":"95000","paymentMethodType":"CARD","customerScore":"1","serviceProvider":"TEST_PROVIDER"}]}"""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.blockstream.data.btcpricehistory.mapper

import com.blockstream.data.btcpricehistory.model.BitcoinChartPeriod
import com.blockstream.data.btcpricehistory.model.NetworkBitcoinPriceData
import com.blockstream.data.data.DataState
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.time.Clock

class BitcoinChartPriceMapperTest {
@Test
fun missingOrUnusableDailyPricesProduceAnEmptyState() {
val responses = listOf(
"""{"currency":"USD"}""",
"""{"currency":"USD","prices_day":[]}""",
"""{"currency":"USD","prices_day":[[],[1000],[null,10],[1000,null]]}""",
"""{"currency":"USD","prices_day":[],"prices_month":[[1000,10]],"prices_full":[[1000,10]]}""",
)
for (response in responses) {
val chart = Json.decodeFromString<NetworkBitcoinPriceData>(response).asChartData()
assertNull(chart, response)
assertEquals(DataState.Empty, DataState.successOrEmpty(chart))
}
}

@Test
fun malformedRowsAreSkippedInEveryPriceSeries() {
val now = Clock.System.now().toEpochMilliseconds()
val earlier = now - 1000
val rows = "[[],[$now,20],[$earlier],[$earlier,10],[null,30],[$now,null]]"
val response = """{"currency":"USD","prices_day":$rows,"prices_month":$rows,"prices_full":$rows}"""

val chart = assertNotNull(Json.decodeFromString<NetworkBitcoinPriceData>(response).asChartData())

assertEquals("USD", chart.currency)
assertEquals(20f, chart.currentPrice)
for (period in BitcoinChartPeriod.entries) {
assertEquals(listOf(earlier to 10f, now to 20f), chart.prices[period], period.name)
}
}

@Test
fun validDailyPricesAreSortedAndTheLatestPriceIsUsed() {
val response = """{"currency":"EUR","prices_day":[[2000,20],[1000,10]]}"""

val chart = assertNotNull(Json.decodeFromString<NetworkBitcoinPriceData>(response).asChartData())

assertEquals("EUR", chart.currency)
assertEquals(20f, chart.currentPrice)
assertEquals(listOf(1000L to 10f, 2000L to 20f), chart.prices[BitcoinChartPeriod.ONE_DAY])
assertEquals(emptyList(), chart.prices[BitcoinChartPeriod.ONE_MONTH])
assertEquals(emptyList(), chart.prices[BitcoinChartPeriod.FIVE_YEAR])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ abstract class DataStateObservableUseCase<P, R> : ObservableUseCase<P, DataState
*/
suspend fun <P, T> DataStateObservableUseCase<P, T>.firstSettled(params: P): DataState<T> {
invoke(params)
return observe().first { !it.isLoading() }
// The shared observer may still replay the value from before this invocation.
return get().first { !it.isLoading() }
}

/**
Expand Down
Loading