commit 374a4e47e9b110f6b364efe92340dce502f93ba1
parent 7f966ae5de07fabd09f9cfcf9e0d6309a378063e
Author: Florian Dold <dold@taler.net>
Date: Wed, 29 Jul 2026 15:59:29 +0200
harness: check that resuming the application does not skip the claim backoff
Issue: https://bugs.taler.net/n/11087
Diffstat:
2 files changed, 160 insertions(+), 0 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-claim-retry-backoff.ts b/packages/taler-harness/src/integrationtests/test-claim-retry-backoff.ts
@@ -0,0 +1,158 @@
+/*
+ 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/>
+ */
+
+/**
+ * Imports.
+ */
+import {
+ NotificationType,
+ succeedOrThrow,
+ TalerMerchantInstanceHttpClient,
+ TransactionMajorState,
+ TransactionMinorState,
+} from "@gnu-taler/taler-util";
+import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
+import {
+ createSimpleTestkudosEnvironmentV3,
+ createWalletDaemonWithClient,
+} from "../harness/environments.js";
+import { delayMs, GlobalTestState } from "../harness/harness.js";
+
+/**
+ * Check that the backoff of a failing claim survives being woken up.
+ *
+ * Claiming an order is the one request the wallet makes before the user has
+ * agreed to anything, so a merchant that answers it with an error must not be
+ * asked again right away. The backoff for that lives in the database, and a
+ * client that hints that the application resumed - which mobile wallets do
+ * whenever they come to the foreground - restarts every task, so it is the
+ * shortest path to retrying without any backoff at all.
+ */
+export async function runClaimRetryBackoffTest(t: GlobalTestState) {
+ const { merchant, merchantAdminAccessToken } =
+ await createSimpleTestkudosEnvironmentV3(t);
+
+ // Each failed attempt is reported as a transition carrying the error, so the
+ // notifications the UI already listens to say how often the wallet tried.
+ const failedAttempts: { transactionId: string; when: number }[] = [];
+
+ const { walletClient } = await createWalletDaemonWithClient(t, {
+ name: "w-claim-backoff",
+ config: {
+ testing: {
+ devModeActive: true,
+ },
+ },
+ handleNotification(wn) {
+ if (
+ wn.type === NotificationType.TransactionStateTransition &&
+ wn.errorInfo != null
+ ) {
+ failedAttempts.push({
+ transactionId: wn.transactionId,
+ when: Date.now(),
+ });
+ }
+ },
+ });
+
+ const merchantClient = new TalerMerchantInstanceHttpClient(
+ merchant.makeInstanceBaseUrl(),
+ );
+
+ const orderResp = succeedOrThrow(
+ await merchantClient.createOrder(merchantAdminAccessToken, {
+ order: {
+ summary: "Buy me!",
+ amount: "TESTKUDOS:5",
+ fulfillment_url: "taler://fulfillment-success/thx",
+ },
+ }),
+ );
+ const orderStatus = succeedOrThrow(
+ await merchantClient.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
+ );
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/block-claim-response",
+ });
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUriV2,
+ {
+ talerPayUri: orderStatus.taler_pay_uri,
+ },
+ );
+
+ function claimAttempts(): number[] {
+ return failedAttempts
+ .filter((x) => x.transactionId === preparePayResult.transactionId)
+ .map((x) => x.when);
+ }
+
+ // Let the wallet fail often enough for the delay between attempts to be a
+ // good few seconds, so that the assertion below has room to run inside it.
+ while (claimAttempts().length < 5) {
+ await delayMs(200);
+ }
+
+ // The wallet just waited this long of its own accord, and the delay it uses
+ // grows, so nothing may happen before that much time has passed again. Read
+ // off what the wallet did rather than assuming a particular curve.
+ const seen = claimAttempts();
+ const lastAttempt = seen[seen.length - 1];
+ const lastDelay = lastAttempt - seen[seen.length - 2];
+ const attemptsBefore = seen.length;
+
+ // Every one of these used to produce another claim.
+ let hints = 0;
+ while (Date.now() < lastAttempt + lastDelay) {
+ await walletClient.call(WalletApiOperation.HintApplicationResumed, {});
+ hints++;
+ await delayMs(100);
+ }
+ t.assertTrue(hints > 0);
+ t.assertDeepEqual(claimAttempts().length, attemptsBefore);
+
+ // Backing off is not the same as giving up: the wallet gets there on its own
+ // once the delay it stored has passed.
+ while (claimAttempts().length === attemptsBefore) {
+ await delayMs(200);
+ }
+
+ // An explicit retry is not a wakeup and still takes effect at once.
+ await walletClient.call(WalletApiOperation.ApplyDevExperiment, {
+ devExperimentUri: "taler://dev-experiment/block-claim-response?val=0",
+ });
+ await walletClient.call(WalletApiOperation.RetryTransaction, {
+ transactionId: preparePayResult.transactionId,
+ });
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: preparePayResult.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ },
+ });
+
+ await t.shutdown();
+}
+
+runClaimRetryBackoffTest.suites = ["wallet"];
diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts
@@ -42,6 +42,7 @@ import { runBalanceProspectiveTest } from "./test-balance-prospective.js";
import { runBankApiTest } from "./test-bank-api.js";
import { runBankWopTest } from "./test-bank-wop.js";
import { runClaimLoopTest } from "./test-claim-loop.js";
+import { runClaimRetryBackoffTest } from "./test-claim-retry-backoff.js";
import { runClauseSchnorrTest } from "./test-clause-schnorr.js";
import { runCoinselLegacy2024Test } from "./test-coinsel-legacy-2024.js";
import { runCurrencyScopeSeparationTest } from "./test-currency-scope-separation.js";
@@ -276,6 +277,7 @@ interface TestMainFunction {
const allTests: TestMainFunction[] = [
runBankApiTest,
runClaimLoopTest,
+ runClaimRetryBackoffTest,
runClauseSchnorrTest,
runDenomRevokedBalanceTest,
runDenomUnofferedTest,