taler-typescript-core

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

commit 031463f7dadd4687981052f116fa1d6bfb55bcd3
parent 2bf1b48cd7f628de0eb189354d73c153e3a9788f
Author: Florian Dold <dold@taler.net>
Date:   Mon, 21 Sep 2026 12:34:39 +0200

wallet-webui: refresh balances after ordinary exchange removal

Revalidate balances and transactions after either exchange deletion mode.
A zero-balance exchange can still contribute a currency choice, so its
removal must refresh those queries even without an exchange notification.

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

Diffstat:
Mpackages/wallet-webui/src/routes/ManagementRoutes.tsx | 2+-
Apackages/wallet-webui/test/exchange-removal.test.tsx | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 193 insertions(+), 1 deletion(-)

diff --git a/packages/wallet-webui/src/routes/ManagementRoutes.tsx b/packages/wallet-webui/src/routes/ManagementRoutes.tsx @@ -344,7 +344,7 @@ export function ExchangeDetailRoute() { const result = await callMutation( WalletApiOperation.DeleteExchange, exchangeDeleteRequest(exchangeUrl, purge), - purge ? ["exchanges", "balances", "transactions"] : ["exchanges"], + ["exchanges", "balances", "transactions"], ); if (Result.isError(result)) { setDeleteMode(undefined); diff --git a/packages/wallet-webui/test/exchange-removal.test.tsx b/packages/wallet-webui/test/exchange-removal.test.tsx @@ -0,0 +1,192 @@ +/* + 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 Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along + with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>. + */ + +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { Result, ScopeType } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { createDemoServices } from "../src/testing/demo-wallet.js"; + +// Match the browser bundle's React alias when rendering SWR-backed routes. +register( + `data:text/javascript,${encodeURIComponent(` + export async function resolve(specifier, context, nextResolve) { + if (specifier === "react" || specifier === "use-sync-external-store/shim/index.js") return { url: ${JSON.stringify(import.meta.resolve("preact/compat"))}, shortCircuit: true }; + return nextResolve(specifier, context); + } +`)}`, + import.meta.url, +); + +test("ordinary exchange removal clears currency choices without notifications", async () => { + const dom = new Window({ url: "https://wallet.example/" }); + for (const key of [ + "window", + "document", + "navigator", + "location", + "history", + "localStorage", + "sessionStorage", + "HTMLElement", + "Element", + "Node", + "Event", + "MutationObserver", + ] as const) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: key === "window" ? dom : dom[key], + }); + } + for (const key of [ + "addEventListener", + "removeEventListener", + "dispatchEvent", + ] as const) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: dom[key].bind(dom), + }); + } + const { render, cleanup, act } = await import("@testing-library/preact"); + const { SWRConfig } = await import("swr"); + const { App } = await import("../src/routes/App.js"); + const services = createDemoServices(); + const originalCall = services.connection.client.call; + const originalResult = services.connection.client.callForResult; + const { exchanges } = await originalCall( + WalletApiOperation.ListExchanges, + {}, + ); + const exchange = exchanges[0]; + let deleted = false; + let balanceReadsAfterDeletion = 0; + services.connection.client.call = async (operation, request) => { + switch (operation) { + case WalletApiOperation.ListExchanges: + return { exchanges: deleted ? [] : [exchange] } as never; + case WalletApiOperation.GetBalances: + if (deleted) balanceReadsAfterDeletion++; + return { + haveProdBalance: !deleted, + balances: deleted + ? [] + : [ + { + scopeInfo: { + type: ScopeType.Exchange, + currency: "CHF", + url: exchange.exchangeBaseUrl, + }, + available: "CHF:0", + pendingIncoming: "CHF:0", + pendingOutgoing: "CHF:0", + flags: [], + }, + ], + } as never; + default: + return originalCall(operation, request); + } + }; + services.connection.client.callForResult = async (operation, request) => { + if (operation === WalletApiOperation.GetExchangeResources) { + return Result.of({ hasResources: false }) as never; + } + if (operation === WalletApiOperation.DeleteExchange) { + assert.deepEqual(request, { + exchangeBaseUrl: exchange.exchangeBaseUrl, + }); + deleted = true; + return Result.of({}) as never; + } + return originalResult(operation, request); + }; + // Removal must refresh its affected queries even if no notification arrives. + services.connection.subscribeNotifications = () => () => {}; + dom.location.hash = `#/exchange/${encodeURIComponent(exchange.exchangeBaseUrl)}`; + const view = render( + <SWRConfig + value={{ + provider: () => new Map(), + shouldRetryOnError: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }} + > + <App {...services} /> + </SWRConfig>, + ); + const waitFor = async (check: () => void) => { + for (let n = 0; ; n++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + try { + check(); + return; + } catch (error) { + if (n >= 200) throw error; + } + } + }; + try { + await waitFor(() => + assert(view.getByRole("button", { name: "Remove exchange" })), + ); + await act(() => + view.getByRole("button", { name: "Remove exchange" }).click(), + ); + await waitFor(() => + assert(view.getByRole("dialog", { name: "Remove exchange?" })), + ); + const dialog = view.getByRole("dialog"); + const confirm = view + .getAllByRole("button", { name: "Remove exchange" }) + .find((button: HTMLElement) => dialog.contains(button)); + assert(confirm); + await act(() => confirm.click()); + await waitFor(() => assert.equal(dom.location.hash, "#/exchanges")); + assert(balanceReadsAfterDeletion > 0); + await act(() => { + dom.location.hash = "#/"; + }); + await waitFor(() => + assert(view.getByRole("heading", { name: "Welcome to Taler Wallet!" })), + ); + for (const mode of ["send", "request"]) { + await act(() => { + dom.location.hash = `#/peer/${mode}`; + }); + await waitFor(() => + assert(view.getByText(new RegExp(`isn’t ready to ${mode} money yet`))), + ); + assert.equal( + view.queryByRole("combobox", { name: "Currency and payment scope" }), + null, + ); + } + } finally { + cleanup(); + services.connection.close(); + await dom.happyDOM.abort(); + } +});