commit 98a2aa68298c7500f3860576fffd8a51233d78a8
parent 2def7e71ddfe87b816f9c71644b0478078c6796c
Author: Florian Dold <dold@taler.net>
Date: Mon, 7 Sep 2026 10:59:06 +0200
merchant-webui: explain locked product deletion failures
Explain product lock conflicts by error code, including responses without
backend hints. Cover the lock conflict and explicit forced deletion in
the merchant portal harness.
Issue: https://bugs.taler.net/n/11405
Diffstat:
5 files changed, 227 insertions(+), 9 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/merchant-webui-transactional-flows.ts b/packages/taler-harness/src/integrationtests/merchant-webui-transactional-flows.ts
@@ -13,6 +13,17 @@
* the expensive service, wallet, and browser setup.
*/
+import assert from "node:assert/strict";
+import type { Page, Route } from "playwright-core";
+import {
+ type AccessToken,
+ Duration,
+ HttpStatusCode,
+ succeedOrThrow,
+ TalerErrorCode,
+ type TalerMerchantInstanceHttpClient,
+} from "@gnu-taler/taler-util";
+
async function submitMutation(
page: any,
method: "POST" | "PATCH" | "DELETE",
@@ -203,6 +214,162 @@ export async function testProductsCrud(
}
}
+/** Regression for #11405: explain a product lock and explicitly force deletion. */
+export async function testLockedProductDeletion(
+ page: Page,
+ webuiUrl: string,
+ client: TalerMerchantInstanceHttpClient,
+ token: AccessToken,
+ saveScreenshot: (name: string) => Promise<void>,
+): Promise<void> {
+ const productId = "harness-locked-product";
+ const productName = "Harness locked product";
+ const productPath = `/private/products/${productId}`;
+ const matchesProduct = (url: URL) => url.pathname.endsWith(productPath);
+ const lockMessage =
+ 'This product is locked by a shopping cart or an unpaid order. Enable "Force deletion" to release its locks and delete the product.';
+ const unrelatedHint = "harness-injected unrelated conflict";
+
+ succeedOrThrow(
+ await client.addProduct(token, {
+ product_id: productId,
+ product_name: productName,
+ description: "Locked inventory regression fixture",
+ price: "CHF:1",
+ unit_price: ["CHF:1"],
+ unit: "piece",
+ total_stock: 10,
+ }),
+ );
+ succeedOrThrow(
+ await client.lockProduct(token, productId, {
+ lock_uuid: "harness-product-deletion-lock",
+ duration: Duration.toTalerProtocolDuration(
+ Duration.fromSpec({ hours: 1 }),
+ ),
+ quantity: 1,
+ }),
+ );
+
+ await page.goto(`${webuiUrl}#/inventory`);
+ // The preceding CRUD flow leaves the categories tab and an empty SWR cache.
+ // Reload so the product created through the API is read into the products tab.
+ await page.reload();
+ await page
+ .getByRole("button", { name: `Actions for ${productName}` })
+ .click();
+ await menuItem(page, "Delete product").click();
+ const dialog = page.getByRole("dialog");
+ const force = dialog.getByRole("checkbox", { name: /Force deletion/ });
+ const lockAlert = dialog.getByRole("alert").filter({ hasText: lockMessage });
+ assert.equal(await force.isChecked(), false);
+
+ const deleteProduct = async (forced: boolean) => {
+ const responsePromise = page.waitForResponse(
+ (response) =>
+ response.request().method() === "DELETE" &&
+ matchesProduct(new URL(response.url())),
+ { timeout: 15_000 },
+ );
+ await dialog
+ .getByRole("button", { name: "Delete Product", exact: true })
+ .click();
+ const response = await responsePromise;
+ assert.equal(new URL(response.url()).search, forced ? "?force=yes" : "");
+ return response;
+ };
+
+ const requireLockConflict = async () => {
+ const response = await deleteProduct(false);
+ assert.equal(response.status(), HttpStatusCode.Conflict);
+ assert.equal(
+ (await response.json()).code,
+ TalerErrorCode.MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK,
+ );
+ await lockAlert.waitFor({ state: "visible", timeout: 15_000 });
+ assert.equal(await force.isChecked(), false);
+ succeedOrThrow(await client.getProductDetails(token, productId));
+ return response;
+ };
+
+ // First exercise the backend's unmodified lock response.
+ await requireLockConflict();
+
+ let injectUnrelatedConflict = false;
+ const handler = async (route: Route) => {
+ if (route.request().method() !== "DELETE") {
+ await route.continue();
+ return;
+ }
+ if (injectUnrelatedConflict) {
+ await route.fulfill({
+ status: HttpStatusCode.Conflict,
+ contentType: "application/json",
+ headers: { "Access-Control-Allow-Origin": "*" },
+ body: JSON.stringify({
+ code: TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE,
+ hint: unrelatedHint,
+ }),
+ });
+ return;
+ }
+ const response = await route.fetch();
+ const body = await response.json();
+ assert.equal(response.status(), HttpStatusCode.Conflict);
+ assert.equal(
+ body.code,
+ TalerErrorCode.MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK,
+ );
+ delete body.hint;
+ await route.fulfill({ response, json: body });
+ };
+ await page.route(matchesProduct, handler);
+ try {
+ // Error-code-based wording must work without the optional backend hint.
+ const response = await requireLockConflict();
+ assert.equal((await response.json()).hint, undefined);
+
+ // HTTP 409 alone must never suggest overriding a product lock.
+ injectUnrelatedConflict = true;
+ const unrelatedResponse = await deleteProduct(false);
+ assert.equal(unrelatedResponse.status(), HttpStatusCode.Conflict);
+ await dialog.getByRole("alert").filter({ hasText: unrelatedHint }).waitFor({
+ state: "visible",
+ timeout: 15_000,
+ });
+ assert.equal(await lockAlert.count(), 0);
+ assert.equal(await force.isChecked(), false);
+ } finally {
+ await page.unroute(matchesProduct, handler);
+ }
+
+ await force.check();
+ const response = await deleteProduct(true);
+ assert.equal(response.status(), HttpStatusCode.NoContent);
+ await dialog.waitFor({ state: "hidden", timeout: 15_000 });
+ await page.getByRole("link", { name: productName, exact: true }).waitFor({
+ state: "hidden",
+ timeout: 15_000,
+ });
+ await page.reload();
+ // Product CRUD removed its fixture before this helper; wait for the loaded
+ // empty inventory, rather than asserting absence before the read completes.
+ await page.getByText("No products yet", { exact: true }).waitFor({
+ state: "visible",
+ timeout: 15_000,
+ });
+ assert.equal(
+ await page.getByRole("link", { name: productName, exact: true }).count(),
+ 0,
+ );
+ const missing = await client.getProductDetails(token, productId);
+ assert.equal(missing.type, "fail");
+ if (missing.type === "fail") {
+ assert.equal(missing.case, HttpStatusCode.NotFound);
+ }
+ await saveScreenshot("06c-locked-product-force-deleted");
+}
+
/**
* 6. Payment templates CRUD operations & detail view.
*/
diff --git a/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts b/packages/taler-harness/src/integrationtests/test-merchant-webui-simple.ts
@@ -57,6 +57,7 @@ import {
import { startStaticServerMerchantWebui } from "../harness/webui-server.js";
import {
testProductsCrud,
+ testLockedProductDeletion,
testTemplatesCrud,
testWebhooksCrud,
testAccessTokensCrud,
@@ -1479,6 +1480,13 @@ export async function runMerchantWebuiSimpleTest(t: GlobalTestState) {
await testBusinessDetails(page, webuiServer.url, saveScreenshot);
await testPersonalization(page, webuiServer.url, saveScreenshot);
await testProductsCrud(page, webuiServer.url, saveScreenshot);
+ await testLockedProductDeletion(
+ page,
+ webuiServer.url,
+ merchantInstanceClient,
+ adminAccessToken,
+ saveScreenshot,
+ );
await testPayoutAccountsInUi(page, webuiServer.url, saveScreenshot);
await testTemplatesCrud(page, webuiServer.url, saveScreenshot);
const liveOrderId = await testOrdersFlowAndPayment(
diff --git a/packages/taler-merchant-webui/src/api/failures.test.tsx b/packages/taler-merchant-webui/src/api/failures.test.tsx
@@ -45,6 +45,45 @@ import { extractErrorDetails } from "../utils/errors.js";
const ROOT = new URL("https://backend.example.test/");
+test("product lock errors explain force deletion while preserving backend diagnostics", () => {
+ for (const hint of [
+ undefined,
+ "The product cannot be deleted until its offer expires.",
+ ]) {
+ const detail = {
+ code: TalerErrorCode.MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK,
+ ...(hint ? { hint } : {}),
+ };
+ const errors = [
+ normalizeApiFailure({ type: "fail", case: 409, detail }),
+ { errorDetail: { httpStatusCode: 409, errorResponse: detail } },
+ new Error("Product deletion failed", {
+ cause: { ...detail, httpStatusCode: 409 },
+ }),
+ ];
+ for (const error of errors) {
+ const displayed = extractErrorDetails(error);
+ assert.match(
+ displayed.message,
+ /locked by a shopping cart or an unpaid order/,
+ );
+ assert.match(
+ displayed.message,
+ /Enable "Force deletion" to release its locks/,
+ );
+ assert.match(
+ displayed.message,
+ /Error 2680: MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK/,
+ );
+ assert.match(displayed.message, /HTTP 409/);
+ assert.doesNotMatch(displayed.message, /until its offer expires/);
+ assert.equal(displayed.code, detail.code);
+ assert.equal(displayed.httpStatus, 409);
+ assert.equal((displayed.rawDetail as typeof detail).hint, hint);
+ }
+ }
+});
+
test("a nested backend failure wins over taler-util's unexpected-status wrapper", () => {
const failure = normalizeApiFailure({
errorDetail: {
diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx
@@ -224,11 +224,7 @@ export function InventoryScreen({
setForceDeleteProduct(false);
} catch (err: unknown) {
setDeleteProductError(
- formatErrorMessage(
- err,
- t`Failed to delete product. Turn on 'Force deletion' below to override active orders or locks.`,
- t,
- ),
+ formatErrorMessage(err, t`Failed to delete product.`, t),
);
} finally {
setIsDeletingProduct(false);
diff --git a/packages/taler-merchant-webui/src/utils/errors.ts b/packages/taler-merchant-webui/src/utils/errors.ts
@@ -115,11 +115,19 @@ function buildErrorMessageWithDetails(
return message;
}
-function explainBrowserNetworkFailure(
+function explainKnownFailure(
backendCode: number | undefined,
message: string | undefined,
t?: TranslateFn,
): string | undefined {
+ if (
+ backendCode ===
+ TalerErrorCode.MERCHANT_PRIVATE_DELETE_PRODUCTS_CONFLICTING_LOCK
+ ) {
+ return t
+ ? t`This product is locked by a shopping cart or an unpaid order. Enable "Force deletion" to release its locks and delete the product.`
+ : 'This product is locked by a shopping cart or an unpaid order. Enable "Force deletion" to release its locks and delete the product.';
+ }
if (backendCode !== TalerErrorCode.WALLET_NETWORK_ERROR) return message;
const explanation = t
? t`The browser could not access an HTTP response. Check the connection, TLS certificate, proxy, browser extensions, and CORS configuration.`
@@ -171,7 +179,7 @@ export function extractErrorDetails(
const codeName =
backendCode !== undefined ? TalerErrorCode[backendCode] : undefined;
- let mainMsg = explainBrowserNetworkFailure(backendCode, backendHint, t);
+ let mainMsg = explainKnownFailure(backendCode, backendHint, t);
if (!mainMsg || mainMsg.startsWith("Unexpected HTTP status")) {
if (backendHint && !backendHint.startsWith("Unexpected HTTP status")) {
mainMsg = backendHint;
@@ -253,7 +261,7 @@ export function extractErrorDetails(
: "The configured merchant backend URL is invalid."
: undefined;
- const baseMsg = explainBrowserNetworkFailure(
+ const baseMsg = explainKnownFailure(
backendCode,
configurationMessage ||
backendHint ||
@@ -306,7 +314,7 @@ export function extractErrorDetails(
cause?.httpStatusCode ?? errObj?.httpStatusCode,
);
- const baseMsg = explainBrowserNetworkFailure(
+ const baseMsg = explainKnownFailure(
backendCode,
backendHint || error.message,
t,