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