taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 789181404c493761a407d0190b0a0a5f11723428
parent d45d8a7b52b4d3d0b3ec9caf9f8b72932abfb563
Author: Florian Dold <dold@taler.net>
Date:   Wed, 22 Jul 2026 15:35:09 +0200

wallet: cover paging over the transaction list

Adds a conformance case pinning that the timestamp cursor cannot separate
records that share a timestamp, and a harness check that paging through the
list yields the same transactions as one unpaginated request.

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-transactions.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 38++++++++++++++++++++++++++++++++++++++
2 files changed, 95 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-transactions.ts b/packages/taler-harness/src/integrationtests/test-wallet-transactions.ts @@ -20,9 +20,11 @@ import { AbsoluteTime, Duration, + GetTransactionsV2Request, j2s, TalerCorebankApiClient, TalerMerchantApi, + TransactionIdStr, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { @@ -156,6 +158,61 @@ export async function runWalletTransactionsTest(t: GlobalTestState) { ); t.assertDeepEqual(txRes.transactions.length, 3); } + + { + // Paging through the list must yield exactly the transactions that one + // unpaginated request returns. + // + // Invoices need no spendable balance, so several can be started at once, + // which gives the paging below more than a handful of transactions and a + // mix of final and non-final ones to walk over. + await Promise.all( + [1, 2, 3, 4, 5].map((n) => + walletClient.call(WalletApiOperation.InitiatePeerPullCredit, { + partialContractTerms: { + amount: "TESTKUDOS:1", + purse_expiration: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 10 }), + ), + ), + summary: `concurrent ${n}`, + }, + }), + ), + ); + + const all = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + {}, + ); + + const paged: string[] = []; + let offset: TransactionIdStr | undefined = undefined; + while (true) { + const req: GetTransactionsV2Request = { limit: 1 }; + if (offset != null) { + req.offsetTransactionId = offset; + } + const page = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + req, + ); + if (page.transactions.length === 0) { + break; + } + for (const tx of page.transactions) { + paged.push(tx.transactionId); + } + offset = page.transactions[page.transactions.length - 1].transactionId; + } + + t.assertDeepEqual( + paged, + all.transactions.map((x) => x.transactionId), + ); + } } runWalletTransactionsTest.suites = ["wallet", "shared-env"]; diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -2782,6 +2782,44 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "transaction meta: the timestamp cursor cannot separate a tie", + async run(t, runner) { + // Timestamps are not unique, and the pagination cursor is a bound on + // the timestamp alone. Advancing it past the last record of a page + // therefore drops every other record that shares that timestamp, which + // is why listing transactions walks the ordered list instead of paging + // through this method. + await runner.runReadWriteTx(async (tx) => { + for (const id of ["txn:tie:a", "txn:tie:b", "txn:tie:c"]) { + await tx.upsertTransactionMeta({ + transactionId: id, + timestamp: tsPrecise(700), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + } + }); + const all = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({}), + ); + t.equal( + all.filter((m) => m.transactionId.startsWith("txn:tie:")).length, + 3, + "all three share one timestamp", + ); + const afterTie = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({ afterTimestamp: tsPrecise(700) }), + ); + t.equal( + afterTie.filter((m) => m.transactionId.startsWith("txn:tie:")).length, + 0, + "continuing after the timestamp skips the whole tie, not just the part already seen", + ); + }, + }, + + { name: "transaction meta: ordered by timestamp, and limited", async run(t, runner) { await runner.runReadWriteTx(async (tx) => {