taler-typescript-core

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

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:
Mpackages/libeufin-bank-webui/package.json | 2++
Apackages/libeufin-bank-webui/src/pages/ActiveWithdrawal.test.tsx | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/libeufin-bank-webui/test.mjs | 19+++++++++++++++++--
Mpnpm-lock.yaml | 6++++++
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))