libeufin

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

commit f44f0ee78843532d36ad71461ab594395a97f758
parent 74187aaded0b0181427940abfb9db6bf3fbf46c7
Author: Antoine A <>
Date:   Sat,  2 Dec 2023 00:45:39 +0000

Add new withdrawal GET endpoint

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt | 2--
Mbank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 3++-
Mbank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 14+++++---------
Mbank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt | 163++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 71+----------------------------------------------------------------------
Mbank/src/test/kotlin/CoreBankApiTest.kt | 5++++-
Mbank/src/test/kotlin/routines.kt | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 180 insertions(+), 156 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -96,8 +96,6 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankConfig) { confirm_transfer_url = confirmUrl )) } - // Make IntelliJ happy. - else -> throw AssertionError("not reached") } } } \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -435,7 +435,8 @@ private fun Routing.coreBankWithdrawalApi(db: Database, ctx: BankConfig) { } get("/withdrawals/{withdrawal_id}") { val uuid = call.uuidUriComponent("withdrawal_id") - val op = db.withdrawal.get(uuid) ?: throw notFound( + val params = StatusParams.extract(call.request.queryParameters) + val op = db.withdrawal.pollInfo(uuid, params) ?: throw notFound( "Withdrawal operation '$uuid' not found", TalerErrorCode.BANK_TRANSACTION_NOT_FOUND ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -343,19 +343,15 @@ data class BankAccountCreateWithdrawalResponse( @Serializable data class WithdrawalPublicInfo ( - val username: String -) - -// Taler withdrawal details response // TODO remove -@Serializable -data class BankAccountGetWithdrawalResponse( + val status: WithdrawalStatus, val amount: TalerAmount, + val username: String, + val selected_reserve_pub: EddsaPublicKey? = null, + val selected_exchange_account: String? = null, + // TODO remove val aborted: Boolean, val confirmation_done: Boolean, val selection_done: Boolean, - val selected_reserve_pub: EddsaPublicKey? = null, - val selected_exchange_account: String? = null, - val username: String ) @Serializable diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/WithdrawalDAO.kt @@ -188,78 +188,12 @@ class WithdrawalDAO(private val db: Database) { stmt.oneOrNull { it.getString(1) } } - /** Get withdrawal operation [uuid] */ - suspend fun get(uuid: UUID): BankAccountGetWithdrawalResponse? = db.conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - (amount).val as amount_val - ,(amount).frac as amount_frac - ,selection_done - ,aborted - ,confirmation_done - ,reserve_pub - ,selected_exchange_payto - ,login - FROM taler_withdrawal_operations - JOIN bank_accounts ON wallet_bank_account=bank_account_id - JOIN customers ON customer_id=owning_customer_id - WHERE withdrawal_uuid=? - """) - stmt.setObject(1, uuid) - stmt.oneOrNull { - BankAccountGetWithdrawalResponse( - amount = it.getAmount("amount", db.bankCurrency), - selection_done = it.getBoolean("selection_done"), - confirmation_done = it.getBoolean("confirmation_done"), - aborted = it.getBoolean("aborted"), - selected_exchange_account = it.getString("selected_exchange_payto"), - selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), - username = it.getString("login") - ) - } - } - - /** Pool public status of operation [uuid] */ - suspend fun pollStatus(uuid: UUID, params: StatusParams): BankWithdrawalOperationStatus? { - suspend fun load(): BankWithdrawalOperationStatus? = db.conn { conn -> - val stmt = conn.prepareStatement(""" - SELECT - CASE - WHEN confirmation_done THEN 'confirmed' - WHEN aborted THEN 'aborted' - WHEN selection_done THEN 'selected' - ELSE 'pending' - END as status - ,(amount).val as amount_val - ,(amount).frac as amount_frac - ,selection_done - ,aborted - ,confirmation_done - ,internal_payto_uri - ,reserve_pub - ,selected_exchange_payto - FROM taler_withdrawal_operations - JOIN bank_accounts ON (wallet_bank_account=bank_account_id) - WHERE withdrawal_uuid=? - """) - stmt.setObject(1, uuid) - stmt.oneOrNull { - BankWithdrawalOperationStatus( - status = WithdrawalStatus.valueOf(it.getString("status")), - amount = it.getAmount("amount", db.bankCurrency), - selection_done = it.getBoolean("selection_done"), - transfer_done = it.getBoolean("confirmation_done"), - aborted = it.getBoolean("aborted"), - sender_wire = it.getString("internal_payto_uri"), - confirm_transfer_url = null, - suggested_exchange = null, - selected_exchange_account = it.getString("selected_exchange_payto"), - selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), - ) - } - } - - + private suspend fun <T> poll( + uuid: UUID, + params: StatusParams, + status: (T) -> WithdrawalStatus, + load: suspend () -> T? + ): T? { return if (params.polling.poll_ms > 0) { db.notifWatcher.listenWithdrawals(uuid) { flow -> coroutineScope { @@ -272,7 +206,7 @@ class WithdrawalDAO(private val db: Database) { // Initial loading val init = load() // Long polling if there is no operation or its not confirmed - if (init?.run { status == params.old_state } ?: true) { + if (init?.run { status(this) == params.old_state } ?: true) { polling.join() load() } else { @@ -285,4 +219,87 @@ class WithdrawalDAO(private val db: Database) { load() } } + + /** Pool public info of operation [uuid] */ + suspend fun pollInfo(uuid: UUID, params: StatusParams): WithdrawalPublicInfo? = + poll(uuid, params, status = { it.status }) { + db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + CASE + WHEN confirmation_done THEN 'confirmed' + WHEN aborted THEN 'aborted' + WHEN selection_done THEN 'selected' + ELSE 'pending' + END as status + ,(amount).val as amount_val + ,(amount).frac as amount_frac + ,selection_done + ,aborted + ,confirmation_done + ,reserve_pub + ,selected_exchange_payto + ,login + FROM taler_withdrawal_operations + JOIN bank_accounts ON wallet_bank_account=bank_account_id + JOIN customers ON customer_id=owning_customer_id + WHERE withdrawal_uuid=? + """) + stmt.setObject(1, uuid) + stmt.oneOrNull { + WithdrawalPublicInfo( + status = WithdrawalStatus.valueOf(it.getString("status")), + amount = it.getAmount("amount", db.bankCurrency), + username = it.getString("login"), + selected_exchange_account = it.getString("selected_exchange_payto"), + selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), + selection_done = it.getBoolean("selection_done"), + confirmation_done = it.getBoolean("confirmation_done"), + aborted = it.getBoolean("aborted"), + ) + } + } + } + + /** Pool public status of operation [uuid] */ + suspend fun pollStatus(uuid: UUID, params: StatusParams): BankWithdrawalOperationStatus? = + poll(uuid, params, status = { it.status }) { + db.conn { conn -> + val stmt = conn.prepareStatement(""" + SELECT + CASE + WHEN confirmation_done THEN 'confirmed' + WHEN aborted THEN 'aborted' + WHEN selection_done THEN 'selected' + ELSE 'pending' + END as status + ,(amount).val as amount_val + ,(amount).frac as amount_frac + ,selection_done + ,aborted + ,confirmation_done + ,internal_payto_uri + ,reserve_pub + ,selected_exchange_payto + FROM taler_withdrawal_operations + JOIN bank_accounts ON (wallet_bank_account=bank_account_id) + WHERE withdrawal_uuid=? + """) + stmt.setObject(1, uuid) + stmt.oneOrNull { + BankWithdrawalOperationStatus( + status = WithdrawalStatus.valueOf(it.getString("status")), + amount = it.getAmount("amount", db.bankCurrency), + selection_done = it.getBoolean("selection_done"), + transfer_done = it.getBoolean("confirmation_done"), + aborted = it.getBoolean("aborted"), + sender_wire = it.getString("internal_payto_uri"), + confirm_transfer_url = null, + suggested_exchange = null, + selected_exchange_account = it.getString("selected_exchange_payto"), + selected_reserve_pub = it.getBytes("reserve_pub")?.run(::EddsaPublicKey), + ) + } + } + } } \ No newline at end of file diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -58,76 +58,7 @@ class BankIntegrationApiTest { } // Check polling - client.postA("/accounts/customer/withdrawals") { - json { "amount" to amount } - }.assertOkJson<BankAccountCreateWithdrawalResponse> { resp -> - val aborted_uuid = resp.taler_withdraw_uri.split("/").last() - val confirmed_uuid = client.postA("/accounts/customer/withdrawals") { - json { "amount" to amount } - }.assertOkJson<BankAccountCreateWithdrawalResponse>() - .taler_withdraw_uri.split("/").last() - - // Check no useless polling - assertTime(0, 100) { - client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=1000&old_state=selected") - .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.pending, it.status) } - } - - // Polling selected - coroutineScope { - launch { // Check polling succeed - assertTime(100, 200) { - client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=1000") - .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.selected, it.status) } - } - } - launch { // Check polling succeed - assertTime(100, 200) { - client.get("/taler-integration/withdrawal-operation/$aborted_uuid?long_poll_ms=1000") - .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.selected, it.status) } - } - } - delay(100) - withdrawalSelect(confirmed_uuid) - withdrawalSelect(aborted_uuid) - } - - // Polling confirmed - coroutineScope { - launch { // Check polling succeed - assertTime(100, 200) { - client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=1000&old_state=selected") - .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.confirmed, it.status)} - } - } - launch { // Check polling timeout - assertTime(200, 300) { - client.get("/taler-integration/withdrawal-operation/$aborted_uuid?long_poll_ms=200&old_state=selected") - .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.selected, it.status) } - } - } - delay(100) - client.post("/withdrawals/$confirmed_uuid/confirm").assertNoContent() - } - - // Polling abort - coroutineScope { - launch { - assertTime(200, 300) { - client.get("/taler-integration/withdrawal-operation/$confirmed_uuid?long_poll_ms=200&old_state=confirmed") - .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.confirmed, it.status)} - } - } - launch { - assertTime(100, 200) { - client.get("/taler-integration/withdrawal-operation/$aborted_uuid?long_poll_ms=1000&old_state=selected") - .assertOkJson<BankWithdrawalOperationStatus> { assertEquals(WithdrawalStatus.aborted, it.status) } - } - } - delay(100) - client.post("/withdrawals/$aborted_uuid/abort").assertNoContent() - } - } + statusRoutine<BankWithdrawalOperationStatus>("/taler-integration/withdrawal-operation") { it.status } // Check unknown client.get("/taler-integration/withdrawal-operation/${UUID.randomUUID()}") diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -762,7 +762,7 @@ class CoreBankWithdrawalApiTest { }.assertOkJson<BankAccountCreateWithdrawalResponse> { client.get("/withdrawals/${it.withdrawal_id}") { pwAuth("merchant") - }.assertOkJson<BankAccountGetWithdrawalResponse> { + }.assertOkJson<WithdrawalPublicInfo> { assert(!it.selection_done) assert(!it.aborted) assert(!it.confirmation_done) @@ -771,6 +771,9 @@ class CoreBankWithdrawalApiTest { } } + // Check polling + statusRoutine<WithdrawalPublicInfo>("/withdrawals") { it.status } + // Check bad UUID client.get("/withdrawals/chocolate").assertBadRequest() diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt @@ -23,6 +23,7 @@ import io.ktor.client.statement.HttpResponse import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.client.request.* import io.ktor.http.* +import kotlin.test.* import kotlinx.coroutines.* import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode @@ -199,4 +200,81 @@ inline suspend fun <reified B> ApplicationTestBuilder.historyRoutine( // backward range: history("delta=-10").assertHistory(10) history("delta=-10&start=${id-4}").assertHistory(10) +} + +inline suspend fun <reified B> ApplicationTestBuilder.statusRoutine( + url: String, + crossinline status: (B) -> WithdrawalStatus +) { + val amount = TalerAmount("KUDOS:9.0") + client.postA("/accounts/customer/withdrawals") { + json { "amount" to amount } + }.assertOkJson<BankAccountCreateWithdrawalResponse> { resp -> + val aborted_uuid = resp.taler_withdraw_uri.split("/").last() + val confirmed_uuid = client.postA("/accounts/customer/withdrawals") { + json { "amount" to amount } + }.assertOkJson<BankAccountCreateWithdrawalResponse>() + .taler_withdraw_uri.split("/").last() + + // Check no useless polling + assertTime(0, 100) { + client.get("$url/$confirmed_uuid?long_poll_ms=1000&old_state=selected") + .assertOkJson<B> { assertEquals(WithdrawalStatus.pending, status(it)) } + } + + // Polling selected + coroutineScope { + launch { // Check polling succeed + assertTime(100, 200) { + client.get("$url/$confirmed_uuid?long_poll_ms=1000") + .assertOkJson<B> { assertEquals(WithdrawalStatus.selected, status(it)) } + } + } + launch { // Check polling succeed + assertTime(100, 200) { + client.get("$url/$aborted_uuid?long_poll_ms=1000") + .assertOkJson<B> { assertEquals(WithdrawalStatus.selected, status(it)) } + } + } + delay(100) + withdrawalSelect(confirmed_uuid) + withdrawalSelect(aborted_uuid) + } + + // Polling confirmed + coroutineScope { + launch { // Check polling succeed + assertTime(100, 200) { + client.get("$url/$confirmed_uuid?long_poll_ms=1000&old_state=selected") + .assertOkJson<B> { assertEquals(WithdrawalStatus.confirmed, status(it))} + } + } + launch { // Check polling timeout + assertTime(200, 300) { + client.get("$url/$aborted_uuid?long_poll_ms=200&old_state=selected") + .assertOkJson<B> { assertEquals(WithdrawalStatus.selected, status(it)) } + } + } + delay(100) + client.post("/withdrawals/$confirmed_uuid/confirm").assertNoContent() + } + + // Polling abort + coroutineScope { + launch { + assertTime(200, 300) { + client.get("$url/$confirmed_uuid?long_poll_ms=200&old_state=confirmed") + .assertOkJson<B> { assertEquals(WithdrawalStatus.confirmed, status(it))} + } + } + launch { + assertTime(100, 200) { + client.get("$url/$aborted_uuid?long_poll_ms=1000&old_state=selected") + .assertOkJson<B> { assertEquals(WithdrawalStatus.aborted, status(it)) } + } + } + delay(100) + client.post("/withdrawals/$aborted_uuid/abort").assertNoContent() + } + } } \ No newline at end of file