taler-typescript-core

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

commit 3ce5467e4ef93b6fd890cecc560104e0130b994b
parent 09f67371c36bbe818d467b654a30abe91b9aad85
Author: Florian Dold <dold@taler.net>
Date:   Mon, 27 Jul 2026 20:11:06 +0200

harness: reproduce empty order listings for ascending pagination

Issue: https://bugs.taler.net/n/11688

Diffstat:
Apackages/taler-harness/src/integrationtests/test-merchant-order-listing.ts | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
2 files changed, 203 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-merchant-order-listing.ts b/packages/taler-harness/src/integrationtests/test-merchant-order-listing.ts @@ -0,0 +1,201 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler 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 General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Reproducer for https://bugs.gnunet.org/view.php?id=11688 + * + * Listing orders with "limit=1&offset=0" returns an empty list, while + * "limit=-1" returns orders. + * + * In the pagination of GET /private/orders, the sign of "limit" only selects + * the direction of the iteration (ascending by row ID for a positive limit) + * and its absolute value bounds the number of results. "offset" is the row ID + * the iteration starts at, and defaults to 0 when iterating ascending. Asking + * for "limit=1&offset=0" must therefore return the oldest order, and not an + * empty list. + * + * The report is about "limit=1", but in fact *every* ascending listing comes + * back empty, because of how the merchant backend derives the lower bound on + * the creation time (taler-merchant-httpd_get-private-orders.c): + * + * struct GNUNET_TIME_Relative duration = GNUNET_TIME_UNIT_FOREVER_REL; + * TALER_MHD_parse_request_rel_time (connection, "max_age", &duration); + * cut_off = GNUNET_TIME_absolute_subtract (GNUNET_TIME_absolute_get (), + * duration); + * po->of.date = GNUNET_TIME_timestamp_max (po->of.date, ...cut_off...); + * + * When "max_age" is absent, TALER_MHD_parse_request_arg_rel_time() does not + * leave the caller's "forever" default alone, it *overwrites* the duration + * with GNUNET_TIME_UNIT_ZERO. The cut-off then becomes "now" instead of the + * epoch, and the ascending query filters on "creation_time > now", which no + * order can satisfy. The descending query uses "creation_time < forever" and + * is unaffected, which is why "limit=-1" works. + */ + +/** + * Imports. + */ +import { + j2s, + succeedOrThrow, + TalerMerchantApi, + TalerMerchantInstanceHttpClient, + URL, +} from "@gnu-taler/taler-util"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/environments.js"; +import { GlobalTestState, harnessHttpLib } from "../harness/harness.js"; + +/** + * Summaries of the orders we create, in creation order. The orders get + * ascending row IDs in that same order. + */ +const orderSummaries = ["First order", "Second order", "Third order"]; + +export async function runMerchantOrderListingTest(t: GlobalTestState) { + // Set up test environment. The orders stay unpaid, so no wallet is + // involved, but the merchant needs a live exchange to accept an order. + + const { merchant, merchantAdminAccessToken: accessToken } = + await createSimpleTestkudosEnvironmentV3(t); + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + + for (const summary of orderSummaries) { + succeedOrThrow( + await merchantClient.createOrder(accessToken, { + order: { + summary, + amount: "TESTKUDOS:1", + fulfillment_url: "taler://fulfillment-success/thx", + }, + }), + ); + } + + /** + * List orders with the given query parameters, spelled out the way the bug + * report does, so that what goes over the wire is obvious here. + */ + async function listOrders( + query: Record<string, string>, + ): Promise<TalerMerchantApi.OrderHistoryEntry[]> { + const url = new URL("private/orders", merchant.makeInstanceBaseUrl()); + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + const resp = await harnessHttpLib.fetch(url.href, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + t.assertDeepEqual(resp.status, 200); + const body = await resp.json(); + console.log(`GET ${url.search} =>`, j2s(body)); + return body.orders; + } + + // Descending, the whole history. This is the direction the merchant SPA + // uses by default, and it works. + { + const orders = await listOrders({ limit: "-20" }); + t.assertDeepEqual( + orders.map((x) => x.summary), + [...orderSummaries].reverse(), + ); + } + + // Descending, one order. From the bug report: this returns the newest + // order. + { + const orders = await listOrders({ limit: "-1" }); + t.assertDeepEqual(orders.length, 1); + t.assertDeepEqual(orders[0].summary, "Third order"); + } + + // Ascending, but with an explicit "max_age" wide enough to cover every + // order. This isolates what breaks the queries below: it goes through the + // very same ascending code path, and it works. + { + const orders = await listOrders({ + limit: "20", + offset: "0", + max_age: `${1000 * 60 * 60 * 24 * 1000}`, + }); + t.assertDeepEqual( + orders.map((x) => x.summary), + orderSummaries, + ); + } + + // Ascending, one order, starting at the beginning of the iteration. From + // the bug report: this returns an empty list instead of the oldest order. + let firstRowId: number; + { + const orders = await listOrders({ limit: "1", offset: "0" }); + t.assertDeepEqual(orders.length, 1); + t.assertDeepEqual(orders[0].summary, "First order"); + firstRowId = orders[0].row_id; + } + + // Ascending without an explicit offset, which defaults to the beginning of + // the iteration for a positive limit. + { + const orders = await listOrders({ limit: "1" }); + t.assertDeepEqual(orders.length, 1); + t.assertDeepEqual(orders[0].summary, "First order"); + } + + // Ascending, the whole history. + { + const orders = await listOrders({ limit: "20", offset: "0" }); + t.assertDeepEqual( + orders.map((x) => x.summary), + orderSummaries, + ); + } + + // Paginating forward from the first order must yield the second one, i.e. + // the offset is exclusive. + { + const orders = await listOrders({ + limit: "1", + offset: String(firstRowId), + }); + t.assertDeepEqual(orders.length, 1); + t.assertDeepEqual(orders[0].summary, "Second order"); + } + + // The same ascending query through the typed client that the web UIs use. + { + const res = succeedOrThrow( + await merchantClient.listOrders(accessToken, { + limit: 1, + order: "asc", + offset: "0", + }), + ); + console.log(`listOrders via the client =>`, j2s(res)); + t.assertDeepEqual(res.orders.length, 1); + t.assertDeepEqual(res.orders[0].summary, "First order"); + } +} + +runMerchantOrderListingTest.suites = ["merchant"]; +// The bug this test reproduces is in the merchant backend, not in the +// TypeScript code. +runMerchantOrderListingTest.todo = true; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -117,6 +117,7 @@ import { runMerchantInstancesUrlsTest } from "./test-merchant-instances-urls.js" import { runMerchantInstancesTest } from "./test-merchant-instances.js"; import { runMerchantKycAuthMultiTest } from "./test-merchant-kyc-auth-multi.js"; import { runMerchantLongpollingTest } from "./test-merchant-longpolling.js"; +import { runMerchantOrderListingTest } from "./test-merchant-order-listing.js"; import { runMerchantPaytoReuseTest } from "./test-merchant-payto-reuse.js"; import { runMerchantRefundApiTest } from "./test-merchant-refund-api.js"; import { runMerchantRefundFeesTest } from "./test-merchant-refund-fees.js"; @@ -472,6 +473,7 @@ const allTests: TestMainFunction[] = [ runTopsMerchantSwtKycauthTest, runMerchantSelfProvisionCasingTest, runTopsMerchantSimpleKycauthTest, + runMerchantOrderListingTest, ]; export interface TestRunSpec {