diff options
author | Antoine A <> | 2024-01-23 18:06:55 +0100 |
---|---|---|
committer | Antoine A <> | 2024-01-23 18:10:59 +0100 |
commit | bb7e455b0f71ba1870f4233f58bcb4bd4fbf05ed (patch) | |
tree | ecb0f851e8781dd8c8100224d079131ec56efea9 /testbench | |
parent | 8aeffb3f9d4fa5323d896a46902ed2384a953cbd (diff) | |
download | libeufin-bb7e455b0f71ba1870f4233f58bcb4bd4fbf05ed.tar.gz libeufin-bb7e455b0f71ba1870f4233f58bcb4bd4fbf05ed.tar.bz2 libeufin-bb7e455b0f71ba1870f4233f58bcb4bd4fbf05ed.zip |
Split utils into common and ebics and ename integration to testbench
Diffstat (limited to 'testbench')
-rw-r--r-- | testbench/build.gradle | 39 | ||||
-rw-r--r-- | testbench/conf/integration.conf | 17 | ||||
-rw-r--r-- | testbench/conf/mini.conf | 2 | ||||
-rw-r--r-- | testbench/conf/netzbon.conf | 28 | ||||
-rw-r--r-- | testbench/conf/postfinance.conf | 27 | ||||
-rw-r--r-- | testbench/src/main/kotlin/Main.kt | 229 | ||||
-rw-r--r-- | testbench/src/test/kotlin/IntegrationTest.kt | 325 |
7 files changed, 667 insertions, 0 deletions
diff --git a/testbench/build.gradle b/testbench/build.gradle new file mode 100644 index 00000000..93fb6a46 --- /dev/null +++ b/testbench/build.gradle @@ -0,0 +1,39 @@ +plugins { + id("kotlin") + id("application") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +compileKotlin.kotlinOptions.jvmTarget = "17" +compileTestKotlin.kotlinOptions.jvmTarget = "17" + +sourceSets.main.java.srcDirs = ["src/main/kotlin"] + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") + + implementation(project(":common")) + implementation(project(":bank")) + implementation(project(":nexus")) + + implementation("com.github.ajalt.clikt:clikt:$clikt_version") + + implementation("org.postgresql:postgresql:$postgres_version") + + implementation("io.ktor:ktor-server-test-host:$ktor_version") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") +} + +application { + mainClass = "tech.libeufin.testbench.MainKt" + applicationName = "libeufin-testbench-test" +} + +run { + standardInput = System.in +}
\ No newline at end of file diff --git a/testbench/conf/integration.conf b/testbench/conf/integration.conf new file mode 100644 index 00000000..39056996 --- /dev/null +++ b/testbench/conf/integration.conf @@ -0,0 +1,17 @@ +[libeufin-bank] +SUGGESTED_WITHDRAWAL_EXCHANGE = https://exchange.example.com +ALLOW_REGISTRATION = yes +ALLOW_ACCOUNT_DELETION = yes +allow_conversion = YES +FIAT_CURRENCY = EUR +tan_sms = libeufin-tan-file.sh +tan_email = libeufin-tan-fail.sh + +[libeufin-bankdb-postgres] +CONFIG = postgresql:///libeufincheck + +[nexus-ebics] +currency = EUR + +[nexus-postgres] +CONFIG = postgres:///libeufincheck diff --git a/testbench/conf/mini.conf b/testbench/conf/mini.conf new file mode 100644 index 00000000..5244dae2 --- /dev/null +++ b/testbench/conf/mini.conf @@ -0,0 +1,2 @@ +[libeufin-bankdb-postgres] +CONFIG = postgresql:///libeufincheck
\ No newline at end of file diff --git a/testbench/conf/netzbon.conf b/testbench/conf/netzbon.conf new file mode 100644 index 00000000..00b140e6 --- /dev/null +++ b/testbench/conf/netzbon.conf @@ -0,0 +1,28 @@ +[nexus-ebics] +CURRENCY = CHF + +# Bank +HOST_BASE_URL = https://ebics.postfinance.ch/ebics/ebics.aspx +BANK_DIALECT = postfinance + +# EBICS IDs +HOST_ID = PFEBICS +USER_ID = 5183101 +PARTNER_ID = 51831 + + +BANK_PUBLIC_KEYS_FILE = test/netzbon/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/netzbon/client-keys.json + +IBAN = CH4009000000160948810 +BIC = POFICHBEXXX +NAME = Genossenschaft Netz Soziale Oekonomie + +[nexus-fetch] +FREQUENCY = 5s + +[nexus-submit] +FREQUENCY = 5s + +[nexus-postgres] +CONFIG = postgres:///libeufincheck diff --git a/testbench/conf/postfinance.conf b/testbench/conf/postfinance.conf new file mode 100644 index 00000000..a5cb15ad --- /dev/null +++ b/testbench/conf/postfinance.conf @@ -0,0 +1,27 @@ +[nexus-ebics] +currency = CHF + +# Bank +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_DIALECT = postfinance + +# EBICS IDs +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +# Key files +BANK_PUBLIC_KEYS_FILE = test/postfinance/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/postfinance/client-keys.json + +#IBAN = CH2989144971918294289 +IBAN = CH7789144474425692816 + +[nexus-fetch] +FREQUENCY = 5s + +[nexus-submit] +FREQUENCY = 5s + +[nexus-postgres] +CONFIG = postgres:///libeufincheck diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt new file mode 100644 index 00000000..b1c549c2 --- /dev/null +++ b/testbench/src/main/kotlin/Main.kt @@ -0,0 +1,229 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Taler Systems S.A. + * + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + * + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.testbench + +import tech.libeufin.nexus.Database as NexusDb +import tech.libeufin.nexus.* +import tech.libeufin.bank.* +import tech.libeufin.common.* +import com.github.ajalt.clikt.core.* +import com.github.ajalt.clikt.parameters.arguments.* +import com.github.ajalt.clikt.parameters.types.* +import com.github.ajalt.clikt.testing.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import kotlin.test.* +import java.io.File +import java.nio.file.* +import java.time.Instant +import kotlinx.coroutines.runBlocking +import io.ktor.client.request.* +import kotlin.io.path.* + +fun randBytes(lenght: Int): ByteArray { + val bytes = ByteArray(lenght) + kotlin.random.Random.nextBytes(bytes) + return bytes +} + +val nexusCmd = LibeufinNexusCommand() +val client = HttpClient(CIO) + +fun step(name: String) { + println("\u001b[35m$name\u001b[0m") +} + +fun ask(question: String): String? { + print("\u001b[;1m$question\u001b[0m") + System.out.flush() + return readlnOrNull() +} + +fun CliktCommandTestResult.assertOk(msg: String? = null) { + println("$output") + assertEquals(0, statusCode, msg) +} + +fun CliktCommandTestResult.assertErr(msg: String? = null) { + println("$output") + assertEquals(1, statusCode, msg) +} + +enum class Kind { + postfinance, + netzbon +} + +class Cli : CliktCommand("Run integration tests on banks provider") { + val kind: Kind by argument().enum<Kind>() + override fun run() { + val name = kind.name + step("Test init $name") + + runBlocking { + Path("test/$name").createDirectories() + val conf = "conf/$name.conf" + val log = "DEBUG" + val flags = " -c $conf -L $log" + val ebicsFlags = "$flags --transient --debug-ebics test/$name" + val cfg = loadConfig(conf) + + val clientKeysPath = Path(cfg.requireString("nexus-ebics", "client_private_keys_file")) + val bankKeysPath = Path(cfg.requireString("nexus-ebics", "bank_public_keys_file")) + + var hasClientKeys = clientKeysPath.exists() + var hasBankKeys = bankKeysPath.exists() + + if (ask("Reset DB ? y/n>") == "y") nexusCmd.test("dbinit -r $flags").assertOk() + else nexusCmd.test("dbinit $flags").assertOk() + val nexusDb = NexusDb("postgresql:///libeufincheck") + + when (kind) { + Kind.postfinance -> { + if (hasClientKeys || hasBankKeys) { + if (ask("Reset keys ? y/n>") == "y") { + if (hasClientKeys) clientKeysPath.deleteIfExists() + if (hasBankKeys) bankKeysPath.deleteIfExists() + hasClientKeys = false + hasBankKeys = false + } + } + + if (!hasClientKeys) { + step("Test INI order") + ask("Got to https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 'Reset EBICS user'.\nPress Enter when done>") + nexusCmd.test("ebics-setup $flags") + .assertErr("ebics-setup should failed the first time") + } + + if (!hasBankKeys) { + step("Test HIA order") + ask("Got to https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 'Activate EBICS user'.\nPress Enter when done>") + nexusCmd.test("ebics-setup --auto-accept-keys $flags") + .assertOk("ebics-setup should succeed the second time") + } + + val payto = "payto://iban/CH2989144971918294289?receiver-name=Test" + + step("Test fetch transactions") + nexusCmd.test("ebics-fetch $ebicsFlags --pinned-start 2022-01-01").assertOk() + + while (true) { + when (ask("Run 'fetch', 'submit', 'tx', 'txs', 'logs', 'ack' or 'exit'>")) { + "fetch" -> { + step("Fetch new transactions") + nexusCmd.test("ebics-fetch $ebicsFlags").assertOk() + } + "tx" -> { + step("Test submit one transaction") + nexusDb.initiatedPaymentCreate(InitiatedPayment( + amount = TalerAmount("CFH:42"), + creditPaytoUri = payto, + wireTransferSubject = "single transaction test", + initiationTime = Instant.now(), + requestUid = Base32Crockford.encode(randBytes(16)) + )) + nexusCmd.test("ebics-submit $ebicsFlags").assertOk() + } + "txs" -> { + step("Test submit many transaction") + repeat(4) { + nexusDb.initiatedPaymentCreate(InitiatedPayment( + amount = TalerAmount("CFH:${100L+it}"), + creditPaytoUri = payto, + wireTransferSubject = "multi transaction test $it", + initiationTime = Instant.now(), + requestUid = Base32Crockford.encode(randBytes(16)) + )) + } + nexusCmd.test("ebics-submit $ebicsFlags").assertOk() + } + "submit" -> { + step("Submit pending transactions") + nexusCmd.test("ebics-submit $ebicsFlags").assertOk() + } + "logs" -> { + step("Fetch logs") + nexusCmd.test("ebics-fetch $ebicsFlags --only-logs").assertOk() + } + "ack" -> { + step("Fetch ack") + nexusCmd.test("ebics-fetch $ebicsFlags --only-ack").assertOk() + } + "exit" -> break + } + } + } + Kind.netzbon -> { + if (!hasClientKeys) + throw Exception("Clients keys are required to run netzbon tests") + + if (!hasBankKeys) { + step("Test HIA order") + nexusCmd.test("ebics-setup --auto-accept-keys $flags").assertOk("ebics-setup should succeed the second time") + } + + step("Test fetch transactions") + nexusCmd.test("ebics-fetch $ebicsFlags --pinned-start 2022-01-01").assertOk() + + while (true) { + when (ask("Run 'fetch', 'submit', 'logs', 'ack' or 'exit'>")) { + "fetch" -> { + step("Fetch new transactions") + nexusCmd.test("ebics-fetch $ebicsFlags").assertOk() + } + "submit" -> { + step("Submit pending transactions") + nexusCmd.test("ebics-submit $ebicsFlags").assertOk() + } + "tx" -> { + step("Submit new transaction") + // TODO interactive payment editor + nexusDb.initiatedPaymentCreate(InitiatedPayment( + amount = TalerAmount("CFH:1.1"), + creditPaytoUri = "payto://iban/CH6208704048981247126?receiver-name=Grothoff%20Hans", + wireTransferSubject = "single transaction test", + initiationTime = Instant.now(), + requestUid = Base32Crockford.encode(randBytes(16)) + )) + nexusCmd.test("ebics-submit $ebicsFlags").assertOk() + } + "logs" -> { + step("Fetch logs") + nexusCmd.test("ebics-fetch $ebicsFlags --only-logs").assertOk() + } + "ack" -> { + step("Fetch ack") + nexusCmd.test("ebics-fetch $ebicsFlags --only-ack").assertOk() + } + "exit" -> break + } + } + } + } + } + + step("Test succeed") + } +} + +fun main(args: Array<String>) { + Cli().main(args) +} diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt new file mode 100644 index 00000000..87f75baa --- /dev/null +++ b/testbench/src/test/kotlin/IntegrationTest.kt @@ -0,0 +1,325 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Taler Systems S.A. + * + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + * + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import org.junit.Test +import tech.libeufin.bank.* +import tech.libeufin.nexus.* +import tech.libeufin.nexus.Database as NexusDb +import tech.libeufin.bank.db.AccountDAO.* +import tech.libeufin.common.* +import java.io.File +import java.time.Instant +import java.util.Arrays +import java.sql.SQLException +import kotlinx.coroutines.runBlocking +import com.github.ajalt.clikt.testing.test +import com.github.ajalt.clikt.core.CliktCommand +import org.postgresql.jdbc.PgConnection +import kotlin.test.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.HttpStatusCode + +fun CliktCommand.run(cmd: String) { + val result = test(cmd) + if (result.statusCode != 0) + throw Exception(result.output) + println(result.output) +} + +fun HttpResponse.assertNoContent() { + assertEquals(HttpStatusCode.NoContent, this.status) +} + +fun randBytes(lenght: Int): ByteArray { + val bytes = ByteArray(lenght) + kotlin.random.Random.nextBytes(bytes) + return bytes +} + +fun server(lambda: () -> Unit) { + // Start the HTTP server in another thread + kotlin.concurrent.thread(isDaemon = true) { + lambda() + } + // Wait for the HTTP server to be up + runBlocking { + HttpClient(CIO) { + install(HttpRequestRetry) { + maxRetries = 10 + constantDelay(200, 100) + } + }.get("http://0.0.0.0:8080/config") + } + +} + +fun setup(lambda: suspend (NexusDb) -> Unit) { + try { + runBlocking { + NexusDb("postgresql:///libeufincheck").use { + lambda(it) + } + } + } finally { + engine?.stop(0, 0) // Stop http server if started + } +} + +inline fun assertException(msg: String, lambda: () -> Unit) { + try { + lambda() + throw Exception("Expected failure: $msg") + } catch (e: Exception) { + assert(e.message!!.startsWith(msg)) { "${e.message}" } + } +} + +class IntegrationTest { + val nexusCmd = LibeufinNexusCommand() + val bankCmd = LibeufinBankCommand(); + val client = HttpClient(CIO) + + @Test + fun mini() { + bankCmd.run("dbinit -c conf/mini.conf -r") + bankCmd.run("passwd admin password -c conf/mini.conf") + bankCmd.run("dbinit -c conf/mini.conf") // Indempotent + + server { + bankCmd.run("serve -c conf/mini.conf") + } + + setup { _ -> + // Check bank is running + client.get("http://0.0.0.0:8080/public-accounts").assertNoContent() + } + } + + @Test + fun errors() { + nexusCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("passwd admin password -c conf/integration.conf") + + suspend fun checkCount(db: NexusDb, nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { + db.conn { conn -> + conn.prepareStatement("SELECT count(*) FROM incoming_transactions").oneOrNull { + assertEquals(nbIncoming, it.getInt(1)) + } + conn.prepareStatement("SELECT count(*) FROM bounced_transactions").oneOrNull { + assertEquals(nbBounce, it.getInt(1)) + } + conn.prepareStatement("SELECT count(*) FROM talerable_incoming_transactions").oneOrNull { + assertEquals(nbTalerable, it.getInt(1)) + } + } + } + + setup { db -> + val userPayTo = IbanPayTo(genIbanPaytoUri()) + val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + + // Load conversion setup manually as the server would refuse to start without an exchange account + val sqlProcedures = File("../database-versioning/libeufin-conversion-setup.sql") + db.conn { + it.execSQLUpdate(sqlProcedures.readText()) + it.execSQLUpdate("SET search_path TO libeufin_nexus;") + } + + val reservePub = randBytes(32) + val payment = IncomingPayment( + amount = TalerAmount("EUR:10"), + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = "Error test ${Base32Crockford.encode(reservePub)}", + executionTime = Instant.now(), + bankId = "error" + ) + + assertException("ERROR: cashin failed: missing exchange account") { + ingestIncomingPayment(db, payment) + } + + // Create exchange account + bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") + + assertException("ERROR: cashin currency conversion failed: missing conversion rates") { + ingestIncomingPayment(db, payment) + } + + // Start server + server { + bankCmd.run("serve -c conf/integration.conf") + } + + // Set conversion rates + client.post("http://0.0.0.0:8080/conversion-info/conversion-rate") { + basicAuth("admin", "password") + json { + "cashin_ratio" to "0.8" + "cashin_fee" to "KUDOS:0.02" + "cashin_tiny_amount" to "KUDOS:0.01" + "cashin_rounding_mode" to "nearest" + "cashin_min_amount" to "EUR:0" + "cashout_ratio" to "1.25" + "cashout_fee" to "EUR:0.003" + "cashout_tiny_amount" to "EUR:0.00000001" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.1" + } + }.assertNoContent() + + assertException("ERROR: cashin failed: admin balance insufficient") { + db.registerTalerableIncoming(payment, reservePub) + } + + // Allow admin debt + bankCmd.run("edit-account admin --debit_threshold KUDOS:100 -c conf/integration.conf") + + // Too small amount + checkCount(db, 0, 0, 0) + ingestIncomingPayment(db, payment.copy( + amount = TalerAmount("EUR:0.01"), + )) + checkCount(db, 1, 1, 0) + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertNoContent() + + // Check success + ingestIncomingPayment(db, IncomingPayment( + amount = TalerAmount("EUR:10"), + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = "Success ${Base32Crockford.encode(randBytes(32))}", + executionTime = Instant.now(), + bankId = "success" + )) + checkCount(db, 2, 1, 1) + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertOkJson<BankAccountTransactionsResponse>() + + // TODO check double insert cashin with different subject + } + } + + @Test + fun conversion() { + nexusCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("passwd admin password -c conf/integration.conf") + bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c conf/integration.conf") + bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") + nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent + bankCmd.run("dbinit -c conf/integration.conf") // Idempotent + + server { + bankCmd.run("serve -c conf/integration.conf") + } + + setup { db -> + val userPayTo = IbanPayTo(genIbanPaytoUri()) + val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + + // Create user + client.post("http://0.0.0.0:8080/accounts") { + basicAuth("admin", "password") + json { + "username" to "customer" + "password" to "password" + "name" to "JohnSmith" + "internal_payto_uri" to userPayTo + "cashout_payto_uri" to fiatPayTo + "debit_threshold" to "KUDOS:100" + "contact_data" to obj { + "phone" to "+99" + } + } + }.assertOkJson<RegisterAccountResponse>() + + // Set conversion rates + client.post("http://0.0.0.0:8080/conversion-info/conversion-rate") { + basicAuth("admin", "password") + json { + "cashin_ratio" to "0.8" + "cashin_fee" to "KUDOS:0.02" + "cashin_tiny_amount" to "KUDOS:0.01" + "cashin_rounding_mode" to "nearest" + "cashin_min_amount" to "EUR:0" + "cashout_ratio" to "1.25" + "cashout_fee" to "EUR:0.003" + "cashout_tiny_amount" to "EUR:0.00000001" + "cashout_rounding_mode" to "zero" + "cashout_min_amount" to "KUDOS:0.1" + } + }.assertNoContent() + + // Cashin + repeat(3) { i -> + val reservePub = randBytes(32); + val amount = TalerAmount("EUR:${20+i}") + val subject = "cashin test $i: ${Base32Crockford.encode(reservePub)}" + ingestIncomingPayment(db, + IncomingPayment( + amount = amount, + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = subject, + executionTime = Instant.now(), + bankId = Base32Crockford.encode(reservePub) + ) + ) + val converted = client.get("http://0.0.0.0:8080/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}") + .assertOkJson<ConversionResponse>().amount_credit + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertOkJson<BankAccountTransactionsResponse> { + val tx = it.transactions.first() + assertEquals(subject, tx.subject) + assertEquals(converted, tx.amount) + } + client.get("http://0.0.0.0:8080/accounts/exchange/taler-wire-gateway/history/incoming") { + basicAuth("exchange", "password") + }.assertOkJson<IncomingHistory> { + val tx = it.incoming_transactions.first() + assertEquals(converted, tx.amount) + assert(Arrays.equals(reservePub, tx.reserve_pub.raw)) + } + } + + // Cashout + repeat(3) { i -> + val requestUid = randBytes(32); + val amount = TalerAmount("KUDOS:${10+i}") + val convert = client.get("http://0.0.0.0:8080/conversion-info/cashout-rate?amount_debit=$amount") + .assertOkJson<ConversionResponse>().amount_credit; + client.post("http://0.0.0.0:8080/accounts/customer/cashouts") { + basicAuth("customer", "password") + json { + "request_uid" to ShortHashCode(requestUid) + "amount_debit" to amount + "amount_credit" to convert + } + }.assertOkJson<CashoutResponse>() + } + } + } +} |