libeufin

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

DatabaseTest.kt (30564B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2024-2025 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 import org.junit.Test
     21 import tech.libeufin.common.*
     22 import tech.libeufin.common.db.*
     23 import tech.libeufin.nexus.AccountType
     24 import tech.libeufin.nexus.NexusIngestConfig
     25 import tech.libeufin.nexus.iso20022.*
     26 import tech.libeufin.nexus.cli.*
     27 import tech.libeufin.nexus.db.*
     28 import tech.libeufin.nexus.db.PaymentDAO.*
     29 import tech.libeufin.nexus.db.InitiatedDAO.*
     30 import tech.libeufin.ebics.*
     31 import java.time.Instant
     32 import java.util.UUID;
     33 import kotlin.test.*
     34 
     35 suspend fun Database.checkInCount(nbIncoming: Int, nbBounce: Int, nbTalerable: Int) = serializable(
     36     """
     37         SELECT (SELECT count(*) FROM incoming_transactions) AS incoming,
     38                (SELECT count(*) FROM bounced_transactions) AS bounce,
     39                (SELECT count(*) FROM talerable_incoming_transactions) AS talerable;
     40     """
     41 ) {
     42     one {
     43         assertEquals(
     44             Triple(nbIncoming, nbBounce, nbTalerable),
     45             Triple(it.getInt("incoming"), it.getInt("bounce"), it.getInt("talerable"))
     46         )
     47     }
     48 }
     49 
     50 suspend fun Database.checkOutCount(nbIncoming: Int, nbTalerable: Int) = serializable(
     51     """
     52         SELECT (SELECT count(*) FROM outgoing_transactions) AS incoming,
     53                (SELECT count(*) FROM talerable_outgoing_transactions) AS talerable;
     54     """
     55 ) {
     56     one {
     57         assertEquals(
     58             Pair(nbIncoming, nbTalerable),
     59             Pair(it.getInt("incoming"), it.getInt("talerable"))
     60         )
     61     }
     62 }
     63 
     64 class OutgoingPaymentsTest {
     65     @Test
     66     fun register() = setup { db, _ -> 
     67         // Register initiated transaction
     68         for (subject in sequenceOf(
     69             "initiated by nexus",
     70             "${ShortHashCode.rand()} https://exchange.com/"
     71         )) {
     72             val pay = genOutPay(subject)
     73             assertIs<PaymentInitiationResult.Success>(
     74                 db.initiated.create(genInitPay(pay.id.endToEndId!!, subject))
     75             )
     76             val first = registerOutgoingPayment(db, pay)
     77             assertEquals(OutgoingRegistrationResult(id = first.id, initiated = true, new = true), first)
     78             assertEquals(
     79                 OutgoingRegistrationResult(id = first.id, initiated = true, new = false),
     80                 registerOutgoingPayment(db, pay)
     81             )
     82 
     83             val refOnly = pay.copy(id = OutgoingId(null, null, acctSvcrRef = pay.id.endToEndId))
     84             val second = registerOutgoingPayment(db, refOnly)
     85             assertEquals(OutgoingRegistrationResult(id = first.id + 1, initiated = false, new = true), second)
     86             assertEquals(
     87                 OutgoingRegistrationResult(id = second.id, initiated = false, new = false),
     88                 registerOutgoingPayment(db, refOnly)
     89             )
     90         }
     91         db.checkOutCount(nbIncoming = 4, nbTalerable = 1)
     92 
     93         // Register unknown
     94         for (subject in sequenceOf(
     95             "not initiated by nexus",
     96             "${ShortHashCode.rand()} https://exchange.com/"
     97         )) {
     98             val pay = genOutPay(subject)
     99             val first = registerOutgoingPayment(db, pay)
    100             assertEquals(OutgoingRegistrationResult(id = first.id, initiated = false, new = true), first)
    101             assertEquals(
    102                 OutgoingRegistrationResult(id = first.id, initiated = false, new = false),
    103                 registerOutgoingPayment(db, pay)
    104             )
    105         }
    106         db.checkOutCount(nbIncoming = 6, nbTalerable = 2)
    107 
    108         // Register wtid reuse
    109         val wtid = ShortHashCode.rand()
    110         for (subject in sequenceOf(
    111             "$wtid https://exchange.com/",
    112             "$wtid https://exchange.com/"
    113         )) {
    114             val pay = genOutPay(subject)
    115             val first = registerOutgoingPayment(db, pay)
    116             assertEquals(OutgoingRegistrationResult(id = first.id, initiated = false, new = true), first)
    117             assertEquals(
    118                 OutgoingRegistrationResult(id = first.id, initiated = false, new = false),
    119                 db.payment.registerOutgoing(pay, null, null)
    120             )
    121         }
    122         db.checkOutCount(nbIncoming = 8, nbTalerable = 3)
    123     }
    124 
    125     @Test
    126     fun registerBatch() = setup { db, _ ->
    127         // Init batch
    128         val wtid = ShortHashCode.rand()
    129         for (subject in sequenceOf(
    130             "initiated by nexus",
    131             "${ShortHashCode.rand()} https://exchange.com/",
    132             "$wtid https://exchange.com/",
    133             "$wtid https://exchange.com/"
    134         )) {
    135             assertIs<PaymentInitiationResult.Success>(
    136                 db.initiated.create(genInitPay(randEbicsId(), subject=subject))
    137             )
    138         }
    139         db.initiated.batch(Instant.now(), "BATCH", false)
    140 
    141         // Register batch
    142         registerOutgoingBatch(db, OutgoingBatch("BATCH", Instant.now()));
    143         db.checkOutCount(nbIncoming = 4, nbTalerable = 2)
    144 
    145         // Test manual ack
    146         val txs = List(3) { nb ->
    147             assertIs<PaymentInitiationResult.Success>(
    148                 db.initiated.create(genInitPay(randEbicsId(), subject="tx $nb"))
    149             ).id
    150         }
    151 
    152         // Check not sent without ack
    153         db.initiated.batch(Instant.now(), "BATCH_MANUAL", true)
    154         registerOutgoingBatch(db, OutgoingBatch("BATCH_MANUAL", Instant.now()));
    155         db.checkOutCount(nbIncoming = 4, nbTalerable = 2)
    156 
    157         // Check sent with ack
    158         for (tx in txs) {
    159             db.initiated.ack(tx)
    160         }
    161         db.initiated.batch(Instant.now(), "BATCH_MANUAL", true)
    162         registerOutgoingBatch(db, OutgoingBatch("BATCH_MANUAL", Instant.now()));
    163         db.checkOutCount(nbIncoming = 7, nbTalerable = 2)
    164     }
    165 }
    166 
    167 class IncomingPaymentsTest {
    168     // Tests creating and bouncing incoming payments in one DB transaction
    169     @Test
    170     fun bounce() = setup { db, _ -> 
    171         // creating and bouncing one incoming transaction.
    172         val payment = genInPay("incoming and bounced")
    173         val id = randEbicsId()
    174         db.payment.registerMalformedIncoming(
    175             payment,
    176             TalerAmount("KUDOS:2.53"),
    177             id,
    178             Instant.now(),
    179             "manual bounce"
    180         ).run {
    181             assertIs<IncomingBounceRegistrationResult.Success>(this)
    182             assertTrue(new)
    183             assertEquals(id, bounceId)
    184         }
    185         db.payment.registerMalformedIncoming(
    186             payment,
    187             TalerAmount("KUDOS:2.53"),
    188             randEbicsId(),
    189             Instant.now(),
    190             "manual bounce"
    191         ).run {
    192             assertIs<IncomingBounceRegistrationResult.Success>(this)
    193             assertFalse(new)
    194             assertEquals(id, bounceId)
    195         }
    196         db.conn {
    197             // Checking one incoming got created
    198             val checkIncoming = it.talerStatement("""
    199                 SELECT (amount).val as amount_value, (amount).frac as amount_frac 
    200                 FROM incoming_transactions WHERE incoming_transaction_id = 1
    201             """).executeQuery()
    202             assertTrue(checkIncoming.next())
    203             assertEquals(payment.amount.value, checkIncoming.getLong("amount_value"))
    204             assertEquals(payment.amount.frac, checkIncoming.getInt("amount_frac"))
    205             // Checking the bounced table got its row.
    206             val checkBounced = it.talerStatement("""
    207                 SELECT 1 FROM bounced_transactions 
    208                 WHERE incoming_transaction_id = 1 AND initiated_outgoing_transaction_id = 1
    209             """).executeQuery()
    210             assertTrue(checkBounced.next())
    211             // check the related initiated payment exists.
    212             val checkInitiated = it.talerStatement("""
    213                 SELECT
    214                     (amount).val as amount_value
    215                     ,(amount).frac as amount_frac
    216                 FROM initiated_outgoing_transactions
    217                 WHERE initiated_outgoing_transaction_id = 1
    218             """).executeQuery()
    219             assertTrue(checkInitiated.next())
    220             assertEquals(
    221                 53000000,
    222                 checkInitiated.getInt("amount_frac")
    223             )
    224             assertEquals(
    225                 2,
    226                 checkInitiated.getInt("amount_value")
    227             )
    228         }
    229     }
    230 
    231     // Test creating an incoming reserve transaction without and ID and reconcile it later again
    232     @Test
    233     fun simple() = setup { db, _ ->
    234         val cfg = NexusIngestConfig.default(AccountType.exchange)
    235         val subject = "test"  
    236         
    237         // Register
    238         val incoming = genInPay(subject)
    239         registerIncomingPayment(db, cfg, incoming)
    240         db.checkInCount(1, 1, 0)
    241 
    242         // Idempotent
    243         registerIncomingPayment(db, cfg, incoming)
    244         db.checkInCount(1, 1, 0)
    245 
    246         // No key reuse
    247         registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9"))
    248         registerIncomingPayment(db, cfg, genInPay("another $subject"))
    249         db.checkInCount(3, 3, 0)
    250 
    251         // Admin balance adjust is ignored
    252         registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST"))
    253         db.checkInCount(4, 3, 0)
    254 
    255         val original = genInPay("test 2")
    256         val incomplete = original.copy(subject = null, debtor = null)
    257         // Register incomplete transaction
    258         registerIncomingPayment(db, cfg, incomplete)
    259         db.checkInCount(5, 3, 0)
    260 
    261         // Idempotent
    262         registerIncomingPayment(db, cfg, incomplete)
    263         db.checkInCount(5, 3, 0)
    264 
    265         // Recover info when complete
    266         registerIncomingPayment(db, cfg, original)
    267         db.checkInCount(5, 4, 0)
    268     }
    269 
    270     // Test creating an incoming reserve taler transaction without and ID and reconcile it later again
    271     @Test
    272     fun talerable() = setup { db, _ ->
    273         val cfg = NexusIngestConfig.default(AccountType.exchange)
    274         val subject = "test with ${EddsaPublicKey.randEdsaKey()} reserve pub"  
    275         
    276         // Register
    277         val incoming = genInPay(subject)
    278         registerIncomingPayment(db, cfg, incoming)
    279         db.checkInCount(1, 0, 1)
    280 
    281         // Idempotent
    282         registerIncomingPayment(db, cfg, incoming)
    283         db.checkInCount(1, 0, 1)
    284 
    285         // Key reuse is bounced
    286         registerIncomingPayment(db, cfg, genInPay(subject, "KUDOS:9"))
    287         registerIncomingPayment(db, cfg, genInPay("another $subject"))
    288         db.checkInCount(3, 2, 1)
    289 
    290         // Admin balance adjust is ignored
    291         registerIncomingPayment(db, cfg, genInPay("ADMIN BALANCE ADJUST"))
    292         db.checkInCount(4, 2, 1)
    293 
    294         val original = genInPay("test 2 with ${EddsaPublicKey.randEdsaKey()} reserve pub")
    295         val incomplete = original.copy(subject = null, debtor = null)
    296         // Register incomplete transaction
    297         registerIncomingPayment(db, cfg, incomplete)
    298         db.checkInCount(5, 2, 1)
    299 
    300         // Idempotent
    301         registerIncomingPayment(db, cfg, incomplete)
    302         db.checkInCount(5, 2, 1)
    303 
    304         // Recover info when complete
    305         registerIncomingPayment(db, cfg, original)
    306         db.checkInCount(5, 2, 2)
    307     }
    308 
    309     @Test 
    310     fun recoverInfo() = setup { db, _ ->
    311         val cfg = NexusIngestConfig.default(AccountType.exchange)
    312 
    313         suspend fun Database.checkContent(payment: IncomingPayment) = serializable(
    314             """
    315                 SELECT
    316                     uetr IS NOT DISTINCT FROM ? AND
    317                     tx_id IS NOT DISTINCT FROM ? AND
    318                     acct_svcr_ref IS NOT DISTINCT FROM ? AND
    319                     subject IS NOT DISTINCT FROM ? AND
    320                     debit_payto IS NOT DISTINCT FROM ?
    321                 FROM incoming_transactions ORDER BY incoming_transaction_id DESC LIMIT 1
    322             """
    323         ) {
    324             bind(payment.id.uetr)
    325             bind(payment.id.txId)
    326             bind(payment.id.acctSvcrRef)
    327             bind(payment.subject)
    328             bind(payment.debtor?.toString())
    329             one {
    330                 assertTrue(it.getBoolean(1))
    331             }
    332         }
    333 
    334         // Non talerable
    335         for ((index, partialId) in sequenceOf(
    336                 IncomingId(UUID.randomUUID(), null, null),
    337                 IncomingId(null, randEbicsId(), null),
    338                 IncomingId(null, null, randEbicsId()),
    339             ).withIndex()) {
    340             val payment = genInPay("subject")
    341 
    342             // Register minimal
    343             val partialPayment = payment.copy(id = partialId, subject = null, debtor = null)
    344             registerIncomingPayment(db, cfg, partialPayment)
    345             db.checkContent(partialPayment)
    346             db.checkInCount(index + 1, index, 0)
    347 
    348             // Recover ID
    349             val fullId = IncomingId(
    350                 partialId.uetr ?: UUID.randomUUID(),
    351                 partialId.txId ?: randEbicsId(),
    352                 partialId.acctSvcrRef ?: randEbicsId()
    353             )
    354             val idPayment = partialPayment.copy(id = fullId)
    355             registerIncomingPayment(db, cfg, idPayment)
    356             db.checkContent(idPayment)
    357             db.checkInCount(index + 1, index, 0)
    358 
    359             // Recover subject & debtor
    360             val fullPayment = payment.copy(id = fullId)
    361             registerIncomingPayment(db, cfg, fullPayment)
    362             db.checkContent(fullPayment)
    363             db.checkInCount(index + 1, index + 1, 0)
    364         }
    365 
    366         // Talerable
    367         for ((index, partialId) in sequenceOf(
    368                 IncomingId(UUID.randomUUID(), null, null),
    369                 IncomingId(null, randEbicsId(), null),
    370                 IncomingId(null, null, randEbicsId()),
    371             ).withIndex()) {
    372             val payment = genInPay("test with ${EddsaPublicKey.randEdsaKey()} reserve pub")
    373 
    374             // Register minimal
    375             val partialPayment = payment.copy(id = partialId, subject = null, debtor = null)
    376             registerIncomingPayment(db, cfg, partialPayment)
    377             db.checkContent(partialPayment)
    378             db.checkInCount(index + 4, 3, index)
    379 
    380             // Recover ID
    381             val fullId = IncomingId(
    382                 partialId.uetr ?: UUID.randomUUID(),
    383                 partialId.txId ?: randEbicsId(),
    384                 partialId.acctSvcrRef ?: randEbicsId()
    385             )
    386             val idPayment = partialPayment.copy(id = fullId)
    387             registerIncomingPayment(db, cfg, idPayment)
    388             db.checkContent(idPayment)
    389             db.checkInCount(index + 4, 3, index)
    390 
    391             // Recover subject & debtor
    392             val fullPayment = payment.copy(id = fullId)
    393             registerIncomingPayment(db, cfg, fullPayment)
    394             db.checkContent(fullPayment)
    395             db.checkInCount(index + 4, 3, index + 1)
    396         }
    397     }
    398 
    399     @Test 
    400     fun horror() = setup { db, _ ->
    401         val cfg = NexusIngestConfig.default(AccountType.exchange)
    402 
    403         // Check we do not bounce already registered talerable transaction
    404         val talerablePayment = genInPay("test with ${EddsaPublicKey.randEdsaKey()} reserve pub")
    405         registerIncomingPayment(db, cfg, talerablePayment)
    406         db.payment.registerMalformedIncoming(
    407             talerablePayment,
    408             TalerAmount("KUDOS:2.53"),
    409             randEbicsId(),
    410             Instant.now(),
    411             "manual bounce"
    412         ).run {
    413             assertEquals(IncomingBounceRegistrationResult.Talerable, this)
    414         }
    415         registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null))
    416         registerIncomingPayment(db, cfg, talerablePayment)
    417         registerIncomingPayment(db, cfg, talerablePayment.copy(subject=null))
    418         db.checkInCount(1, 0, 1)
    419 
    420         // Check we do not register as talerable bounced transaction
    421         val bouncedPayment = genInPay("bounced ${EddsaPublicKey.randEdsaKey()}")
    422         registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null))
    423         registerIncomingPayment(db, cfg, bouncedPayment)
    424         registerIncomingPayment(db, cfg, bouncedPayment.copy(subject=null))
    425         registerIncomingPayment(db, cfg, bouncedPayment)
    426         db.checkInCount(2, 1, 1)
    427     }
    428 }
    429 
    430 class PaymentInitiationsTest {
    431 
    432     // Test skipping transaction based on config
    433     @Test
    434     fun skipping() = setup("skip.conf") { db, cfg ->
    435         suspend fun checkCount(nbTxs: Int, nbBounce: Int) {
    436             db.serializable(
    437                 """
    438                     SELECT (SELECT count(*) FROM incoming_transactions) + (SELECT count(*) FROM outgoing_transactions) AS transactions,
    439                         (SELECT count(*) FROM bounced_transactions) AS bounce
    440                 """
    441             ) {
    442                 one {
    443                     assertEquals(
    444                         Pair(nbTxs, nbBounce),
    445                         Pair(it.getInt("transactions"), it.getInt("bounce"))
    446                     )
    447                 }
    448             }
    449         }
    450 
    451         suspend fun ingest(executionTime: Instant) {
    452             for (tx in sequenceOf(
    453                 genInPay("test at $executionTime", executionTime = executionTime),
    454                 genOutPay("test at $executionTime", executionTime = executionTime)
    455             )) {
    456                 registerTransaction(db, cfg.ingest, tx)
    457             }
    458         }
    459 
    460         assertEquals(cfg.fetch.ignoreTransactionsBefore, dateToInstant("2024-04-04"))
    461         assertEquals(cfg.fetch.ignoreBouncesBefore, dateToInstant("2024-06-12"))
    462 
    463         // No transaction at the beginning
    464         checkCount(0, 0)
    465 
    466         // Skipped transactions
    467         ingest(cfg.fetch.ignoreTransactionsBefore.minusMillis(10))
    468         checkCount(0, 0)
    469 
    470         // Skipped bounces
    471         ingest(cfg.fetch.ignoreTransactionsBefore)
    472         ingest(cfg.fetch.ignoreTransactionsBefore.plusMillis(10))
    473         ingest(cfg.fetch.ignoreBouncesBefore.minusMillis(10))
    474         checkCount(6, 0)
    475 
    476         // Bounces
    477         ingest(cfg.fetch.ignoreBouncesBefore)
    478         ingest(cfg.fetch.ignoreBouncesBefore.plusMillis(10))
    479         checkCount(10, 2)
    480     }
    481 
    482     @Test
    483     fun status() = setup { db, _ ->
    484         suspend fun checkPart(
    485             batchId: Long, 
    486             batchStatus: SubmissionState, 
    487             batchMsg: String?, 
    488             txStatus: SubmissionState, 
    489             txMsg: String?,
    490             settledStatus: SubmissionState, 
    491             settledMsg: String?,
    492         ) {
    493             // Check batch status
    494             val msgId = db.serializable(
    495                 """
    496                 SELECT message_id, status, status_msg FROM initiated_outgoing_batches WHERE initiated_outgoing_batch_id=?
    497                 """
    498             ) {
    499                 bind(batchId)
    500                 one {
    501                     val msgId = it.getString("message_id")
    502                     assertEquals(
    503                         batchStatus to batchMsg,
    504                         it.getEnum<SubmissionState>("status") to it.getString("status_msg"),
    505                         msgId
    506                     )
    507                     msgId
    508                 }
    509             }
    510             // Check tx status
    511             db.serializable(
    512                 """
    513                 SELECT end_to_end_id, status, status_msg FROM initiated_outgoing_transactions WHERE initiated_outgoing_batch_id=?
    514                 """
    515             ) {
    516                 bind(batchId)
    517                 all { 
    518                     val endToEndId = it.getString("end_to_end_id")
    519                     val expected = when (endToEndId) {
    520                         "TX" -> Pair(txStatus, txMsg)
    521                         "TX_SETTLED" -> Pair(settledStatus, settledMsg)
    522                         else -> throw Exception("Unexpected tx $endToEndId")
    523                     }
    524                     assertEquals(
    525                         expected,
    526                         it.getEnum<SubmissionState>("status") to it.getString("status_msg"),
    527                         "$msgId.$endToEndId"
    528                     )
    529                 }
    530             }
    531         }
    532         suspend fun checkBatch(batchId: Long, status: SubmissionState, msg: String?, txStatus: SubmissionState? = null) {
    533             val txStatus = txStatus ?: status
    534             checkPart(batchId, status, msg, txStatus, msg, txStatus, msg)
    535         }
    536         suspend fun checkOrder(orderId: String, status: SubmissionState, msg: String?, txStatus: SubmissionState? = null) {
    537             val batchId = db.serializable(
    538                 "SELECT initiated_outgoing_batch_id FROM initiated_outgoing_batches WHERE order_id=?"
    539             ) {
    540                 bind(orderId)
    541                 one { 
    542                     it.getLong("initiated_outgoing_batch_id")
    543                 }
    544             }
    545             checkBatch(batchId, status, msg, txStatus)
    546         }
    547 
    548         suspend fun test(lambda: suspend (Long) -> Unit) {
    549             // Reset DB
    550             db.conn { conn -> 
    551                 conn.execSQLUpdate("DELETE FROM initiated_outgoing_transactions");
    552                 conn.execSQLUpdate("DELETE FROM initiated_outgoing_batches");
    553             }
    554             // Create a test batch with three transactions
    555             for (id in sequenceOf("TX", "TX_SETTLED")) {
    556                 assertIs<PaymentInitiationResult.Success>(
    557                     db.initiated.create(genInitPay(id))
    558                 )
    559             }
    560             db.initiated.batch(Instant.now(), "BATCH", false)
    561             // Create witness transactions and batch
    562             for (id in sequenceOf("WITNESS_1", "WITNESS_2")) {
    563                 assertIs<PaymentInitiationResult.Success>(
    564                     db.initiated.create(genInitPay(id))
    565                 )
    566             }
    567             db.initiated.batch(Instant.now(), "BATCH_WITNESS", false)
    568             for (id in sequenceOf("WITNESS_3", "WITNESS_4")) {
    569                 assertIs<PaymentInitiationResult.Success>(
    570                     db.initiated.create(genInitPay(id))
    571                 )
    572             }
    573             // Check everything is unsubmitted
    574             db.serializable(
    575                 """
    576                 SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches)
    577                    AND (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_transactions)
    578                 """
    579             ) {
    580                 one { assertTrue(it.getBoolean(1)) }
    581             }
    582             // Run test
    583             lambda(db.initiated.submittable().find { it.messageId == "BATCH" }!!.id)
    584             // Check witness status is unaltered
    585             db.serializable(
    586                 """
    587                 SELECT (SELECT bool_and(status = 'unsubmitted') FROM initiated_outgoing_batches WHERE message_id != 'BATCH')
    588                    AND (SELECT bool_and(initiated_outgoing_transactions.status = 'unsubmitted') 
    589                             FROM initiated_outgoing_transactions JOIN initiated_outgoing_batches USING (initiated_outgoing_batch_id)
    590                             WHERE message_id != 'BATCH')
    591                 """
    592             ) {
    593                 one { assertTrue(it.getBoolean(1)) }
    594             }
    595         }
    596 
    597         // Submission retry status
    598         test { batchId ->
    599             db.initiated.batchSubmissionFailure(batchId, Instant.now(), "First failure")
    600             checkBatch(batchId, SubmissionState.transient_failure, "First failure")
    601             db.initiated.batchSubmissionFailure(batchId, Instant.now(), "Second failure")
    602             checkBatch(batchId, SubmissionState.transient_failure, "Second failure")
    603             db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
    604             checkOrder("ORDER", SubmissionState.pending, null)
    605             db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
    606             checkOrder("ORDER", SubmissionState.pending, null)
    607             db.initiated.orderStep("ORDER", "step msg")
    608             checkOrder("ORDER", SubmissionState.pending, "step msg")
    609             db.initiated.orderStep("ORDER", "success msg")
    610             checkOrder("ORDER", SubmissionState.pending, "success msg")
    611             db.initiated.orderSuccess("ORDER")
    612             checkOrder("ORDER", SubmissionState.success, "success msg", SubmissionState.pending)
    613             db.initiated.orderStep("ORDER", "late msg")
    614             checkOrder("ORDER", SubmissionState.success, "success msg", SubmissionState.pending)
    615         }
    616 
    617         // Order step message on failure
    618         test { batchId ->
    619             db.initiated.batchSubmissionSuccess(batchId, Instant.now(), "ORDER")
    620             checkOrder("ORDER", SubmissionState.pending, null)
    621             db.initiated.orderStep("ORDER", "step msg")
    622             checkOrder("ORDER", SubmissionState.pending, "step msg")
    623             db.initiated.orderStep("ORDER", "failure msg")
    624             checkOrder("ORDER", SubmissionState.pending, "failure msg")
    625             assertEquals("failure msg", db.initiated.orderFailure("ORDER")!!.second)
    626             checkOrder("ORDER", SubmissionState.permanent_failure, "failure msg")
    627             db.initiated.orderStep("ORDER", "late msg")
    628             checkOrder("ORDER", SubmissionState.permanent_failure, "failure msg")
    629         }
    630 
    631         // Payment & batch status
    632         test { batchId ->
    633             checkBatch(batchId, SubmissionState.unsubmitted, null)
    634             db.initiated.batchStatusUpdate("BATCH", StatusUpdate.pending, "progress")
    635             checkBatch(batchId, SubmissionState.pending, "progress")
    636             db.initiated.txStatusUpdate("TX_SETTLED", null, StatusUpdate.success, "success")
    637             checkPart(batchId, SubmissionState.pending, "progress", SubmissionState.pending, "progress", SubmissionState.success, "success")
    638             db.initiated.batchStatusUpdate("BATCH", StatusUpdate.transient_failure, "waiting")
    639             checkPart(batchId, SubmissionState.transient_failure, "waiting", SubmissionState.transient_failure, "waiting", SubmissionState.success, "success")
    640             db.initiated.txStatusUpdate("TX", "BATCH", StatusUpdate.permanent_failure, "failure")
    641             checkPart(batchId, SubmissionState.success, null, SubmissionState.permanent_failure, "failure", SubmissionState.success, "success")
    642             db.initiated.txStatusUpdate("TX_SETTLED", "BATCH", StatusUpdate.permanent_failure, "late")
    643             checkPart(batchId, SubmissionState.success, null, SubmissionState.permanent_failure, "failure", SubmissionState.late_failure, "late")
    644         }
    645 
    646         // Registration
    647         test { batchId ->
    648             checkBatch(batchId, SubmissionState.unsubmitted, null)
    649             registerOutgoingPayment(db, genOutPay("", endToEndId = "TX_SETTLED"))
    650             checkPart(batchId, SubmissionState.unsubmitted, null, SubmissionState.unsubmitted, null, SubmissionState.success, null)
    651             registerOutgoingPayment(db, genOutPay("", endToEndId = "TX", msgId = "BATCH"))
    652             checkPart(batchId, SubmissionState.success, null, SubmissionState.success, null, SubmissionState.success, null)
    653         }
    654 
    655         // Transaction failure take over batch failures
    656         test { batchId -> 
    657             checkBatch(batchId, SubmissionState.unsubmitted, null)
    658             db.initiated.batchStatusUpdate("BATCH", StatusUpdate.permanent_failure, "batch")
    659             checkPart(batchId, SubmissionState.permanent_failure, "batch", SubmissionState.permanent_failure, "batch", SubmissionState.permanent_failure, "batch")
    660             db.initiated.txStatusUpdate("TX", "BATCH", StatusUpdate.permanent_failure, "tx")
    661             db.initiated.batchStatusUpdate("BATCH", StatusUpdate.permanent_failure, "batch2")
    662             checkPart(batchId, SubmissionState.permanent_failure, "batch", SubmissionState.permanent_failure, "tx", SubmissionState.permanent_failure, "batch")
    663         }
    664        
    665         // Unknown order and batch
    666         db.initiated.batchSubmissionSuccess(42, Instant.now(), "ORDER_X")
    667         db.initiated.batchSubmissionFailure(42, Instant.now(), null)
    668         db.initiated.orderStep("ORDER_X", "msg")
    669         db.initiated.batchStatusUpdate("BATCH_X", StatusUpdate.success, null)
    670         db.initiated.txStatusUpdate("TX_X", "BATCH_X", StatusUpdate.success, "msg")
    671         assertNull(db.initiated.orderSuccess("ORDER_X"))
    672         assertNull(db.initiated.orderFailure("ORDER_X"))
    673     }
    674 
    675     @Test
    676     fun submittable() = setup { db, _ -> 
    677         repeat(6) {
    678             assertIs<PaymentInitiationResult.Success>(
    679                 db.initiated.create(genInitPay("PAY$it"))
    680             )
    681             db.initiated.batch(Instant.now(), "BATCH$it", false)
    682         }
    683         suspend fun checkIds(vararg ids: String) {
    684             assertEquals(
    685                 listOf(*ids),
    686                 db.initiated.submittable().flatMap { it.payments.map { it.endToEndId } }
    687             )
    688         }
    689         checkIds("PAY0", "PAY1", "PAY2", "PAY3", "PAY4", "PAY5")
    690 
    691         // Check submitted not submitable
    692         db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1")
    693         checkIds("PAY1", "PAY2", "PAY3", "PAY4", "PAY5")
    694 
    695         // Check transient failure submitable last
    696         db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
    697         checkIds("PAY2", "PAY3", "PAY4", "PAY5", "PAY1")
    698 
    699         // Check persistent failure not submitable
    700         db.initiated.batchSubmissionSuccess(4, Instant.now(), "ORDER3")
    701         db.initiated.orderFailure("ORDER3")
    702         checkIds("PAY2", "PAY4", "PAY5", "PAY1")
    703         db.initiated.batchSubmissionSuccess(5, Instant.now(), "ORDER4")
    704         db.initiated.orderFailure("ORDER4")
    705         checkIds("PAY2", "PAY5", "PAY1")
    706 
    707         // Check rotation
    708         db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure")
    709         checkIds("PAY5", "PAY1", "PAY2")
    710         db.initiated.batchSubmissionFailure(6, Instant.now(), "Failure")
    711         checkIds("PAY1", "PAY2", "PAY5")
    712         db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
    713         checkIds("PAY2", "PAY5", "PAY1")
    714     }
    715 
    716     // TODO test for unsettledTxInBatch
    717 }
    718 
    719 class EbicsTxTest {
    720     // Test pending transaction's id
    721     @Test
    722     fun pending() = setup { db, _ ->
    723         val ids = setOf("first", "second", "third")
    724         for (id in ids) {
    725             db.ebics.register(id)
    726         }
    727 
    728         repeat(ids.size) {
    729             val id = db.ebics.first()
    730             assert(ids.contains(id))
    731             db.ebics.remove(id!!)
    732         }
    733 
    734         assertNull(db.ebics.first())
    735     }
    736 }