libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit b6f550f6a97341b8dada4fb770d8a9bf786ced54
parent 14718fdb0635f67663670f64999acdebb3793482
Author: MS <ms@taler.net>
Date:   Mon, 22 May 2023 12:14:27 +0200

Conversion service tests.

Testing HTTP error paths in the cash-out monitor.

Diffstat:
Mnexus/src/test/kotlin/ConversionServiceTest.kt | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
1 file changed, 159 insertions(+), 78 deletions(-)

diff --git a/nexus/src/test/kotlin/ConversionServiceTest.kt b/nexus/src/test/kotlin/ConversionServiceTest.kt @@ -12,8 +12,10 @@ import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.transactions.transaction import org.junit.Ignore import org.junit.Test +import tech.libeufin.nexus.bankaccount.getBankAccount import tech.libeufin.nexus.server.nexusApp import tech.libeufin.sandbox.* +import tech.libeufin.util.internalServerError import tech.libeufin.util.parseAmount class ConversionServiceTest { @@ -93,6 +95,57 @@ class ConversionServiceTest { } } } + private fun CoroutineScope.launchCashoutMonitor(httpClient: HttpClient): Job { + val job = launch { + /** + * The runInterruptible wrapper lets code without suspension + * points be cancel()'d. Without it, such code would ignore + * any call to cancel() and the test never return. + */ + runInterruptible { + /** + * Without the runBlocking wrapper, cashoutMonitor doesn't + * compile. That's because it is a 'suspend' function and + * it needs a coroutine environment to execute; runInterruptible + * does NOT provide one. Furthermore, replacing runBlocking + * with "launch {}" would nullify runInterruptible, due to other + * jobs that cashoutMonitor internally launches and would escape + * the interruptible policy. + */ + runBlocking { cashoutMonitor(httpClient) } + } + } + return job + } + + // This function mocks a 500 response to a cash-out request. + private fun MockRequestHandleScope.mock500Response(request: HttpRequestData): HttpResponseData { + return respondError(HttpStatusCode.InternalServerError) + } + // This function implements a mock server that checks the currency in the cash-out request. + private suspend fun MockRequestHandleScope.inspectCashoutCurrency(request: HttpRequestData): HttpResponseData { + // Asserting that the currency is indeed the FIAT. + return if (request.url.encodedPath == "/bank-accounts/foo/payment-initiations" && request.method == HttpMethod.Post) { + val body = jacksonObjectMapper().readTree(request.body.toByteArray()) + val postedAmount = body.get("amount").asText() + assert(parseAmount(postedAmount).currency == "FIAT") + respondOk("cash-out-nonce") + } else { + println("Cash-out monitor wrongly requested to: ${request.url}") + // This is a minimal Web server that support only the above endpoint. + respondError(status = HttpStatusCode.NotImplemented) + } + } + + private fun getMockClient(handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): HttpClient { + return HttpClient(MockEngine) { + engine { + addHandler { + request -> handler(request) + } + } + } + } /** * Checks that the cash-out monitor reacts after @@ -106,59 +159,33 @@ class ConversionServiceTest { cashoutCurrency = "FIAT" ) prepNexusDb() - wireTransfer( - debitAccount = "foo", - creditAccount = "admin", - subject = "fiat #0", - amount = "REGIO:3" - ) testApplication { application(nexusApp) - /** - * This construct allows to capture the HTTP request that the cash-out - * monitor (that runs in Sandbox) does to Nexus letting check that the - * currency mentioned in the fiat payment initiations is indeed the fiat - * currency. - */ + // Mock server to intercept and inspect the cash-out request. val checkCurrencyClient = HttpClient(MockEngine) { engine { addHandler { - request -> - if (request.url.encodedPath == "/bank-accounts/foo/payment-initiations" && request.method == HttpMethod.Post) { - val body = jacksonObjectMapper().readTree(request.body.toByteArray()) - val postedAmount = body.get("amount").asText() - assert(parseAmount(postedAmount).currency == "FIAT") - respondOk("cash-out-nonce") - } else { - println("Cash-out monitor wrongly requested to: ${request.url}") - // This is a minimal Web server that support only the above endpoint. - respondError(status = HttpStatusCode.NotImplemented) - } + request -> inspectCashoutCurrency(request) } } } + // Starting the cash-out monitor with the mocked client. runBlocking { - val job = launch { - /** - * The runInterruptible wrapper lets code without suspension - * points be cancel()'d. Without it, such code would ignore - * any call to cancel() and the test never return. - */ - runInterruptible { - /** - * Without the runBlocking wrapper, cashoutMonitor doesn't - * compile. That's because it is a 'suspend' function and - * it needs a coroutine environment to execute; runInterruptible - * does NOT provide one. Furthermore, replacing runBlocking - * with "launch {}" would nullify runInterruptible, due to other - * jobs that cashoutMonitor internally launches and would escape - * the interruptible policy. - */ - runBlocking { cashoutMonitor(checkCurrencyClient) } - } - } + var job = launchCashoutMonitor(checkCurrencyClient) + // Following are various cases of a cash-out scenario. + + /** + * 1, Ordinary/successful case. We test that the conversion + * service sent indeed one request to Nexus and that the currency + * is correct. + */ + wireTransfer( + debitAccount = "foo", + creditAccount = "admin", + subject = "fiat #0", + amount = "REGIO:3" + ) delay(1000L) // Lets DB persist the information. - job.cancelAndJoin() // Checking now the Sandbox side, and namely that one // cash-out operation got carried out. transaction { @@ -170,48 +197,101 @@ class ConversionServiceTest { */ assert(op.maybeNexusResposnse == "cash-out-nonce") } - } - } - } - } - - /** - * Tests whether the conversion service is able to skip - * submissions that had problems and proceed to new ones. - ---------------------------------------------------------- - * Ignoring the test because the new version just fails the - * process on client side errors. Still however keeping the - * (ignored) test as a model to create faulty situations. - */ - @Ignore - @Test - fun testWrongSubmissionSkip() { - withTestDatabase { - prepSandboxDb(); prepNexusDb() - val engine400 = MockEngine { respondBadRequest() } - val mockedClient = HttpClient(engine400) - runBlocking { - val monitorJob = async(Dispatchers.IO) { cashoutMonitor(mockedClient) } - launch { + /* 2, Internal server error case. We test that after requesting + * to a failing Nexus, the last accounted cash-out did NOT increase. + */ + job.cancelAndJoin() + val error500Client = HttpClient(MockEngine) { + engine { + addHandler { + request -> mock500Response(request) + } + } + } + job = launchCashoutMonitor(error500Client) + // Sending a new payment to trigger the conversion service. wireTransfer( debitAccount = "foo", creditAccount = "admin", - subject = "fiat", - amount = "TESTKUDOS:3" + subject = "fiat #1", + amount = "REGIO:2" ) - // Give enough time to let a flawed monitor submit the request twice. - delay(6000) + delay(1000L) // Lets the reaction complete. + job.cancelAndJoin() transaction { - // The request was submitted only once. - assert(CashoutSubmissionEntity.all().count() == 1L) - // The monitor marked it as failed. - assert(CashoutSubmissionEntity.all().first().hasErrors) - // The submission pointer got advanced by one. - assert(getBankAccountFromLabel("admin").lastFiatSubmission?.id?.value == 1L) + val bankaccount = getBankAccountFromLabel("admin") + // Checks that the counter did NOT increase. + assert(bankaccount.lastFiatSubmission?.id?.value == 1L) + } + /* Removing now the mocked 500 response and checking that + * indeed the cash-out does get sent. */ + job = launchCashoutMonitor(client) // Should find the non cashed-out wire transfer and react. + delay(1000L) // Lets the reaction complete. + job.cancelAndJoin() + transaction { + val bankaccount = getBankAccountFromLabel("admin") + // Checks that the once failing cash-out did go through. + assert(bankaccount.lastFiatSubmission?.subject == "fiat #1") } - monitorJob.cancel() + /** + * 3, the client error case, where the conversion service is + * supposed to exit the whole process. + */ + job = launchCashoutMonitor( + getMockClient { + /** + * This causes the cash-out request sent to Nexus to + * respond with 400. + */ + respondBadRequest() + } + ) // Should find the non cashed-out wire transfer and react. + // Triggering now a cash-out operation via a new wire transfer to admin. + wireTransfer( + debitAccount = "foo", + creditAccount = "admin", + subject = "fiat #2", + amount = "REGIO:22" + ) + delay(1000L) // Lets the reaction complete. + job.cancelAndJoin() + // Checking that the cash-out counter did NOT update. + transaction { + val bankaccount = getBankAccountFromLabel("admin") + // Checks that the once failing cash-out did go through. + assert(bankaccount.lastFiatSubmission?.subject == "fiat #1") + } + /** + * 4, checking a redirect response. Because this is an unhandled + * error case, it is treated as a client error. No need to wire a + * new cash-out to trigger a cash-out request, since the last failing + * one will be retried. + */ + job = launchCashoutMonitor( + getMockClient { + /** + * This causes the cash-out request sent to Nexus to + * respond with 307 Temporary Redirect. + */ + respondRedirect() + } + ) + assert(job.isActive) + delay(1000L) // Lets the reaction complete. + // Checking that the service stopped because of the client-side error. + assert(!job.isActive) + // 5, Mocking a network error. The previous failed cash-out + // will again trigger the service to POST at Nexus. + job = launchCashoutMonitor( + getMockClient { + throw Exception("Network Issue.") + } + ) + delay(1000L) // Lets the reaction complete. + assert(job.isActive) // asserting that the service is still running. + job.cancelAndJoin() } } } } -} +} +\ No newline at end of file