diff --git a/data/build.gradle.kts b/data/build.gradle.kts index ea205b6951..5ac96dac6f 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -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) @@ -231,4 +232,4 @@ tasks.configureEach { tasks.getByName("clean").doFirst { delete(project.file("src/include")) delete(project.file("src/libs")) -} \ No newline at end of file +} diff --git a/data/src/commonMain/kotlin/com/blockstream/data/btcpricehistory/mapper/BitcoinChartPriceMapper.kt b/data/src/commonMain/kotlin/com/blockstream/data/btcpricehistory/mapper/BitcoinChartPriceMapper.kt index a4368eeefc..dfc2158dcf 100644 --- a/data/src/commonMain/kotlin/com/blockstream/data/btcpricehistory/mapper/BitcoinChartPriceMapper.kt +++ b/data/src/commonMain/kotlin/com/blockstream/data/btcpricehistory/mapper/BitcoinChartPriceMapper.kt @@ -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>>() //timestamp, price val dailyPrices = data.dailyPrices.mapAndSortNotNullPrices() + val currentPrice = dailyPrices.lastOrNull()?.second ?: return null val monthlyPrices = data.monthlyPrices.mapAndSortNotNullPrices() val fullPrices = data.fullPrices.mapAndSortNotNullPrices() @@ -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( @@ -43,4 +43,4 @@ private fun List>.mapAndSortNotNullPrices(): List> = emptyList() - get() = field.sortedBy { it[0] } + get() = field.sortedBy { it.getOrNull(0) } @SerialName("prices_full") val fullPrices: List> = emptyList() - get() = field.sortedBy { it[0] } + get() = field.sortedBy { it.getOrNull(0) } @SerialName("prices_month") val monthlyPrices: List> = emptyList() - get() = field.sortedBy { it[0] } + get() = field.sortedBy { it.getOrNull(0) } } diff --git a/data/src/commonMain/kotlin/com/blockstream/data/meld/MeldHttpClient.kt b/data/src/commonMain/kotlin/com/blockstream/data/meld/MeldHttpClient.kt index b7fe3ab8bf..a152db453d 100644 --- a/data/src/commonMain/kotlin/com/blockstream/data/meld/MeldHttpClient.kt +++ b/data/src/commonMain/kotlin/com/blockstream/data/meld/MeldHttpClient.kt @@ -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 @@ -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" } -} \ No newline at end of file +} diff --git a/data/src/commonTest/kotlin/MeldRepositoryTest.kt b/data/src/commonTest/kotlin/MeldRepositoryTest.kt index 6e81b25697..0d927ae7e8 100644 --- a/data/src/commonTest/kotlin/MeldRepositoryTest.kt +++ b/data/src/commonTest/kotlin/MeldRepositoryTest.kt @@ -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"}]}""" + } } diff --git a/data/src/commonTest/kotlin/com/blockstream/data/btcpricehistory/mapper/BitcoinChartPriceMapperTest.kt b/data/src/commonTest/kotlin/com/blockstream/data/btcpricehistory/mapper/BitcoinChartPriceMapperTest.kt new file mode 100644 index 0000000000..8ac09ae719 --- /dev/null +++ b/data/src/commonTest/kotlin/com/blockstream/data/btcpricehistory/mapper/BitcoinChartPriceMapperTest.kt @@ -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(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(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(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]) + } +} diff --git a/domain/src/commonMain/kotlin/com/blockstream/domain/base/DataStateObservableUseCase.kt b/domain/src/commonMain/kotlin/com/blockstream/domain/base/DataStateObservableUseCase.kt index 54b570f617..54218c63e1 100644 --- a/domain/src/commonMain/kotlin/com/blockstream/domain/base/DataStateObservableUseCase.kt +++ b/domain/src/commonMain/kotlin/com/blockstream/domain/base/DataStateObservableUseCase.kt @@ -122,7 +122,8 @@ abstract class DataStateObservableUseCase : ObservableUseCase DataStateObservableUseCase.firstSettled(params: P): DataState { invoke(params) - return observe().first { !it.isLoading() } + // The shared observer may still replay the value from before this invocation. + return get().first { !it.isLoading() } } /** diff --git a/domain/src/commonTest/kotlin/com/blockstream/domain/base/FirstSettledTest.kt b/domain/src/commonTest/kotlin/com/blockstream/domain/base/FirstSettledTest.kt new file mode 100644 index 0000000000..71e65d92b5 --- /dev/null +++ b/domain/src/commonTest/kotlin/com/blockstream/domain/base/FirstSettledTest.kt @@ -0,0 +1,74 @@ +package com.blockstream.domain.base + +import com.blockstream.data.data.DataState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class FirstSettledTest { + private class RefreshUseCase : DataStateObservableUseCase() { + var next: DataState = DataState.Success(1) + var canExecute = true + val releaseObserver = CompletableDeferred() + + override fun shouldExecute(params: Unit) = canExecute + + override suspend fun doAsyncWork(params: Unit) = set(next) + + override fun createObservable(params: Unit): Flow> = get().onEach { + // Keep the shared observer behind a completed refresh, without relying on timing. + if (it != DataState.Success(1)) releaseObserver.await() + } + + fun publish(state: DataState) = set(state) + } + + @Test + fun completedRefreshDoesNotReturnThePreviousSharedReplay() = runTest { + val results = listOf(DataState.Success(2), DataState.Empty, DataState.Error(Exception("refresh failed"))) + for (result in results) { + val useCase = RefreshUseCase() + try { + useCase(Unit) + assertEquals(DataState.Success(1), useCase.observe().first()) + useCase.next = result + + assertEquals(result, useCase.firstSettled()) + assertEquals(result, useCase.getCurrent()) + } finally { + useCase.releaseObserver.complete(Unit) + } + } + } + + @Test + fun skippedRefreshReturnsTheCurrentSettledState() = runTest { + val useCase = RefreshUseCase() + useCase(Unit) + useCase.canExecute = false + useCase.next = DataState.Success(2) + + assertEquals(DataState.Success(1), useCase.firstSettled(Unit)) + } + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun skippedRefreshWaitsWhileTheCurrentStateIsLoading() = runTest { + val useCase = RefreshUseCase() + useCase.canExecute = false + val result = async { useCase.firstSettled() } + runCurrent() + assertFalse(result.isCompleted) + + useCase.publish(DataState.Empty) + + assertEquals(DataState.Empty, result.await()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c60471d516..300321ca77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -93,6 +93,7 @@ coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } constraintlayout-compose-multiplatform = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayoutComposeMultiplatform" } kotlin-multiplatform-appdirs = { module = "ca.gosyer:kotlin-multiplatform-appdirs", version.ref = "kotlinMultiplatformAppdirs" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-resources = { module = "io.ktor:ktor-client-resources", version.ref = "ktor" } diff --git a/network/src/commonMain/kotlin/com/blockstream/network/AppHttpClient.kt b/network/src/commonMain/kotlin/com/blockstream/network/AppHttpClient.kt index 8a008b05ae..5c2e2d23a5 100644 --- a/network/src/commonMain/kotlin/com/blockstream/network/AppHttpClient.kt +++ b/network/src/commonMain/kotlin/com/blockstream/network/AppHttpClient.kt @@ -3,6 +3,7 @@ package com.blockstream.network import co.touchlab.kermit.Logger import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ServerResponseException @@ -65,9 +66,11 @@ inline fun NetworkResponse.exception(): Exception { } abstract class AppHttpClient( - enableLogging: Boolean = false, configBlock: HttpClientConfig<*>.() -> Unit = {} + enableLogging: Boolean = false, + configBlock: HttpClientConfig<*>.() -> Unit = {}, + engine: HttpClientEngine? = null, ) { - val httpClient: HttpClient = defaultHttpClient(enableLogging, configBlock) + val httpClient: HttpClient = defaultHttpClient(enableLogging, configBlock, engine) suspend inline fun get( path: String, block: HttpRequestBuilder.() -> Unit = {} @@ -150,9 +153,11 @@ abstract class AppHttpClient( companion object { private fun defaultHttpClient( - enableLogging: Boolean = false, configBlock: HttpClientConfig<*>.() -> Unit = {} + enableLogging: Boolean, + configBlock: HttpClientConfig<*>.() -> Unit, + engine: HttpClientEngine?, ): HttpClient { - return HttpClient { + val configure: HttpClientConfig<*>.() -> Unit = { install(Resources) if (enableLogging) { install(Logging) { @@ -180,6 +185,7 @@ abstract class AppHttpClient( } configBlock() } + return if (engine == null) HttpClient(configure) else HttpClient(engine, configure) } } @@ -189,4 +195,4 @@ private suspend fun HttpResponse.bodyAsTextOrNull(): String? = try { this.bodyAsText() } catch (_: Exception) { null -} \ No newline at end of file +}