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:
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) => {