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:
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();
+ }
+});