commit 9dc1a6440ccc74097cd72c28bf44740f2419459d
parent 15420f0e27a5a1a1b6109dfd594eefc2a8f9a22f
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 10:08:58 +0100
bank web UI: cover withdrawal recovery and completion races
Diffstat:
4 files changed, 405 insertions(+), 2 deletions(-)
diff --git a/packages/libeufin-bank-webui/package.json b/packages/libeufin-bank-webui/package.json
@@ -28,12 +28,14 @@
"devDependencies": {
"@gnu-taler/pogen": "workspace:*",
"@tailwindcss/forms": "^0.5.3",
+ "@testing-library/preact": "^3.2.4",
"@types/chai": "^4.3.0",
"@types/history": "^4.7.8",
"@types/node": "^20.19.41",
"autoprefixer": "^10.4.14",
"chai": "^6.2.2",
"esbuild": "^0.28.0",
+ "happy-dom": "^20.11.6",
"tailwindcss": "3.4.17",
"typescript": "7.0.2"
},
diff --git a/packages/libeufin-bank-webui/src/pages/ActiveWithdrawal.test.tsx b/packages/libeufin-bank-webui/src/pages/ActiveWithdrawal.test.tsx
@@ -0,0 +1,380 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+import assert from "node:assert/strict";
+import { afterEach, beforeEach, test } from "node:test";
+import { Window } from "happy-dom";
+import { ComponentChildren, h } from "preact";
+import { act } from "preact/test-utils";
+import { cleanup, render } from "@testing-library/preact";
+import { setupI18n, TalerError } from "@gnu-taler/taler-util";
+import {
+ BankApiProviderTesting,
+ BrowserHashNavigationProvider,
+ RouteDefinition,
+ useBankCoreApiContext,
+} from "@gnu-taler/web-util/browser";
+import { BankStoryContext } from "../stories-context.js";
+import { useWithdrawalDetails } from "../hooks/account.js";
+import { ActiveWithdrawal as ActiveRecord } from "../hooks/bank-state.js";
+import { ActiveWithdrawal } from "./ActiveWithdrawal.js";
+import {
+ useWithdrawalOperationState,
+ WithdrawalDetailsResult,
+} from "./OperationState/state.js";
+import { WithdrawalOperationPage } from "./WithdrawalOperationPage.js";
+
+const baseUrl = "http://bank.example/";
+const routeClose: RouteDefinition = { pattern: /^\/$/, url: () => "#/" as any };
+const routeOperation = {
+ url: ({ wopid }: { wopid: string }) => `#/operation/${wopid}`,
+} as RouteDefinition<{ wopid: string }>;
+const remembered: ActiveRecord = {
+ operationId: "withdrawal-1",
+ username: "alice",
+ backendBaseUrl: baseUrl,
+ confirmationDeferred: true,
+ kind: "standard",
+};
+let dom: Window;
+let actions: string[];
+let requests: Array<{
+ id: string;
+ backend: string;
+ resolve(value: WithdrawalDetailsResult): void;
+ reject(error: Error): void;
+}>;
+
+beforeEach(() => {
+ dom = new Window({ url: baseUrl });
+ for (const [key, value] of Object.entries({
+ window: dom,
+ document: dom.document,
+ navigator: dom.navigator,
+ Node: dom.Node,
+ Element: dom.Element,
+ HTMLElement: dom.HTMLElement,
+ Event: dom.Event,
+ MutationObserver: dom.MutationObserver,
+ }))
+ Object.defineProperty(globalThis, key, {
+ configurable: true,
+ writable: true,
+ value,
+ });
+ setupI18n("en", { en: {} });
+ requests = [];
+ actions = [];
+});
+afterEach(async () => {
+ cleanup();
+ await dom.happyDOM.abort();
+});
+
+function response(
+ status: "pending" | "selected" | "confirmed" | "aborted",
+ extra = {},
+): WithdrawalDetailsResult {
+ return {
+ type: "ok",
+ body: {
+ status,
+ username: "alice",
+ amount: "EUR:5",
+ selected_reserve_pub: "reserve",
+ selected_exchange_account:
+ "payto://iban/DE02120300000000202051?receiver-name=Exchange",
+ ...extra,
+ },
+ } as WithdrawalDetailsResult;
+}
+function seed(active = remembered) {
+ dom.localStorage.setItem(
+ "bank-app-state",
+ JSON.stringify({ activeWithdrawal: active }),
+ );
+}
+function saved(): ActiveRecord | undefined {
+ return JSON.parse(dom.localStorage.getItem("bank-app-state") ?? "{}")
+ .activeWithdrawal;
+}
+async function flush() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+async function answer(index: number, value: WithdrawalDetailsResult) {
+ await act(async () => {
+ requests[index].resolve(value);
+ });
+ await flush();
+}
+function Api({
+ children,
+ backend,
+}: {
+ children: ComponentChildren;
+ backend: string;
+}) {
+ const original = useBankCoreApiContext();
+ return (
+ <BankApiProviderTesting
+ value={{
+ ...original,
+ url: new URL(backend),
+ lib: {
+ ...original.lib,
+ bank: Object.assign(Object.create(original.lib.bank), {
+ confirmWithdrawalById: async () => {
+ actions.push("confirm");
+ return { type: "ok", body: {} };
+ },
+ abortWithdrawalById: async () => {
+ actions.push("abort");
+ return { type: "ok", body: {} };
+ },
+ getAccount: async () => ({
+ type: "ok",
+ body: {
+ balance: {
+ amount: "EUR:100",
+ credit_debit_indicator: "credit",
+ },
+ debit_threshold: "EUR:0",
+ },
+ }),
+ getWithdrawalById: (id: string) =>
+ new Promise<WithdrawalDetailsResult>((resolve, reject) =>
+ requests.push({ id, backend, resolve, reject }),
+ ),
+ }),
+ },
+ }}
+ >
+ {children}
+ </BankApiProviderTesting>
+ );
+}
+function Providers({
+ children,
+ backend = baseUrl,
+}: {
+ children: ComponentChildren;
+ backend?: string;
+}) {
+ return (
+ <BrowserHashNavigationProvider>
+ <BankStoryContext
+ loggedIn
+ config={{ currency: "EUR", wire_type: "IBAN" }}
+ >
+ <Api backend={backend}>{children}</Api>
+ </BankStoryContext>
+ </BrowserHashNavigationProvider>
+ );
+}
+// Exercise lifecycle effects without the route conflict redirect in isolation tests.
+function Probe({ id }: { id: string }) {
+ const state = useWithdrawalOperationState({
+ operationId: id,
+ routeClose,
+ clearWhenTerminal: true,
+ recoverActiveWithdrawal: true,
+ onAbort: () => undefined,
+ });
+ return <output>{state.status}</output>;
+}
+function Screen({ id }: { id?: string }) {
+ return (
+ <ActiveWithdrawal
+ username="alice"
+ openOperationId={id}
+ routeOperation={routeOperation}
+ routeClose={routeClose}
+ >
+ {id ? (
+ <WithdrawalOperationPage
+ operationId={id}
+ routeClose={routeClose}
+ onOperationAborted={() => undefined}
+ />
+ ) : (
+ <p>Account</p>
+ )}
+ </ActiveWithdrawal>
+ );
+}
+
+test("completed detail stays cleared while its URL remains open and after leaving", async () => {
+ seed();
+ const view = render(<Screen id="withdrawal-1" />, { wrapper: Providers });
+ await flush();
+ await answer(0, response("confirmed"));
+ assert.equal(saved(), undefined);
+ view.rerender(<Screen id="withdrawal-1" />);
+ await flush();
+ assert.equal(saved(), undefined);
+ view.rerender(<Screen />);
+ await flush();
+ assert.equal(view.container.querySelector("#active-withdrawal-title"), null);
+ assert.equal(requests.length, 1);
+});
+
+test("reload hides a remembered completed withdrawal before and after checking", async () => {
+ seed();
+ const view = render(<Screen />, { wrapper: Providers });
+ await flush();
+ assert.equal(view.container.querySelector("#active-withdrawal-title"), null);
+ assert.deepEqual(saved(), remembered);
+ await answer(0, response("confirmed"));
+ assert.equal(saved(), undefined);
+ assert.equal(view.container.querySelector("#active-withdrawal-title"), null);
+});
+
+test("a pending bookmark is recovered and its account notice waits for verification", async () => {
+ const view = render(<Screen id="withdrawal-1" />, { wrapper: Providers });
+ await flush();
+ assert.equal(saved(), undefined);
+ const pending = response("pending", { no_amount_to_wallet: true });
+ await answer(0, pending);
+ assert.deepEqual(saved(), {
+ ...remembered,
+ confirmationDeferred: false,
+ kind: "cash-acceptor",
+ });
+ view.rerender(<Screen />);
+ await flush();
+ assert.equal(view.container.querySelector("#active-withdrawal-title"), null);
+ await answer(1, pending);
+ assert.match(view.container.textContent ?? "", /waiting for your wallet/);
+});
+
+test("a network failure preserves recovery and shows the error notice", async () => {
+ seed();
+ const view = render(<Screen />, { wrapper: Providers });
+ await flush();
+ await act(async () => requests[0].reject(new Error("offline")));
+ await flush();
+ assert.match(view.container.textContent ?? "", /could not be checked/);
+ assert.deepEqual(saved(), remembered);
+});
+
+test("recovery does not adopt another account's operation", async () => {
+ render(<Probe id="withdrawal-1" />, { wrapper: Providers });
+ await flush();
+ await answer(0, response("pending", { username: "bob" }));
+ assert.equal(saved(), undefined);
+});
+
+for (const change of ["operation", "backend"] as const) {
+ test(`late responses after changing ${change} cannot clear the current withdrawal`, async () => {
+ const view = render(
+ <Providers>
+ <Probe id="withdrawal-1" />
+ </Providers>,
+ );
+ await flush();
+ const nextId = change === "operation" ? "withdrawal-2" : "withdrawal-1";
+ const nextBackend =
+ change === "backend" ? "http://other-bank.example/" : baseUrl;
+ view.rerender(
+ <Providers backend={nextBackend}>
+ <Probe id={nextId} />
+ </Providers>,
+ );
+ await flush();
+ assert.equal(requests.length, 2);
+ assert.equal(requests[1].id, nextId);
+ assert.equal(requests[1].backend, nextBackend);
+ await answer(1, response("pending"));
+ const active = saved();
+ assert.equal(active?.operationId, nextId);
+ assert.equal(active?.backendBaseUrl, nextBackend);
+ await answer(0, response("confirmed"));
+ assert.deepEqual(saved(), active);
+ assert.equal(view.getByText("ready").textContent, "ready");
+ });
+}
+
+test("previous results stay hidden across repeated renders after an ID change", async () => {
+ const seen: Array<string | undefined> = [];
+ function Poll({ id }: { id: string }) {
+ const result = useWithdrawalDetails(id);
+ const status =
+ result && !(result instanceof TalerError) && result.type === "ok"
+ ? result.body.status
+ : undefined;
+ seen.push(status);
+ return <output>{status ?? "loading"}</output>;
+ }
+ const view = render(<Poll id="withdrawal-1" />, { wrapper: Providers });
+ await flush();
+ await answer(0, response("confirmed"));
+ seen.length = 0;
+ view.rerender(<Poll id="withdrawal-2" />);
+ view.rerender(<Poll id="withdrawal-2" />);
+ await flush();
+ assert.ok(seen.every((value) => value === undefined));
+ assert.equal(requests.length, 2);
+});
+
+for (const action of ["confirm", "abort"] as const) {
+ test(`${action} clears a recovered withdrawal before polling reports completion`, async () => {
+ // Cover both existing metadata and recovery of a selected bookmark through
+ // the real action buttons, without a separate synthetic completion path.
+ const existing =
+ action === "confirm"
+ ? { ...remembered, kind: "cash-acceptor" as const }
+ : undefined;
+ if (existing) seed(existing);
+ const view = render(<Screen id="withdrawal-1" />, { wrapper: Providers });
+ await flush();
+ assert.deepEqual(saved(), existing);
+ await answer(0, response("selected"));
+ assert.deepEqual(
+ saved(),
+ existing ?? { ...remembered, confirmationDeferred: false },
+ );
+ if (action === "abort") {
+ await act(async () =>
+ view.getByRole("button", { name: "Abort withdrawal" }).click(),
+ );
+ }
+ const button = view.getByRole("button", {
+ name: action === "confirm" ? "Confirm withdrawal" : "Abort withdrawal",
+ });
+ assert.equal((button as HTMLButtonElement).disabled, false);
+ await act(async () => button.click());
+ await flush();
+ assert.deepEqual(actions, [action]);
+ assert.equal(saved(), undefined);
+ view.rerender(<Screen id="withdrawal-1" />);
+ await flush();
+ assert.equal(saved(), undefined);
+ view.rerender(<Screen />);
+ await flush();
+ assert.equal(
+ view.container.querySelector("#active-withdrawal-title"),
+ null,
+ );
+ });
+}
+
+for (const [field, value] of [
+ ["operationId", "withdrawal-2"],
+ ["username", "bob"],
+ ["backendBaseUrl", "http://other-bank.example/"],
+] as const) {
+ test(`terminal cleanup preserves a withdrawal with a different ${field}`, async () => {
+ const other = { ...remembered, [field]: value };
+ seed(other);
+ render(<Probe id="withdrawal-1" />, { wrapper: Providers });
+ await flush();
+ await answer(0, response("confirmed"));
+ assert.deepEqual(saved(), other);
+ });
+}
diff --git a/packages/libeufin-bank-webui/test.mjs b/packages/libeufin-bank-webui/test.mjs
@@ -15,12 +15,17 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { build } from "@gnu-taler/web-util/build";
+import { build } from "esbuild";
+import { writeFileSync } from "node:fs";
+import {
+ computeConfig,
+ emptyTestOutputDirectory,
+} from "@gnu-taler/web-util/build";
import { getFilesInDirectory } from "@gnu-taler/web-util/build";
const allTestFiles = getFilesInDirectory("src", /\.test\.tsx?$/);
-await build({
+const { esBuildOptions, version } = computeConfig({
type: "test",
source: {
js: allTestFiles.files,
@@ -29,3 +34,13 @@ await build({
destination: "./dist/test",
css: "sass",
});
+
+// Happy DOM runs in Node; keep its Node-only dependencies out of the browser bundle.
+esBuildOptions.external.push("happy-dom");
+emptyTestOutputDirectory(esBuildOptions.absWorkingDir, esBuildOptions.outdir);
+const result = await build(esBuildOptions);
+writeFileSync(`${esBuildOptions.outdir}/version.txt`, version);
+writeFileSync(
+ `${esBuildOptions.outdir}/build-metadata.json`,
+ JSON.stringify(result.metafile),
+);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -215,6 +215,9 @@ importers:
'@tailwindcss/forms':
specifier: ^0.5.3
version: 0.5.3(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@20.19.41)(typescript@7.0.2)))
+ '@testing-library/preact':
+ specifier: ^3.2.4
+ version: 3.2.4(preact@10.29.8)
'@types/chai':
specifier: ^4.3.0
version: 4.3.3
@@ -233,6 +236,9 @@ importers:
esbuild:
specifier: ^0.28.0
version: 0.28.0
+ happy-dom:
+ specifier: ^20.11.6
+ version: 20.11.6
tailwindcss:
specifier: 3.4.17
version: 3.4.17(ts-node@10.9.1(@types/node@20.19.41)(typescript@7.0.2))