taler-typescript-core

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

commit 729fa8b322e181728838385605521e1c9eb3babb
parent 282f611c3feb7eb7b4cde1d865f5c259fceaaf3c
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 23:17:55 +0100

merchant web UI: invalidate editor and catalog caches after mutations

Diffstat:
Apackages/taler-merchant-webui/src/api/cache.test.tsx | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/api/cache.ts | 31+++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/api/hooks/useInventory.ts | 19+++++++++++++++++++
Mpackages/taler-merchant-webui/src/api/hooks/useTemplates.ts | 13+++++++++++++
Mpackages/taler-merchant-webui/src/api/hooks/useTokenFamilies.ts | 3+++
Mpackages/taler-merchant-webui/src/api/hooks/useWebhooks.ts | 13+++++++++++++
Mpackages/taler-merchant-webui/src/routes/CreateProductRoute.tsx | 12++----------
Apackages/taler-merchant-webui/src/testing/swr-cache.ts | 23+++++++++++++++++++++++
8 files changed, 285 insertions(+), 10 deletions(-)

diff --git a/packages/taler-merchant-webui/src/api/cache.test.tsx b/packages/taler-merchant-webui/src/api/cache.test.tsx @@ -0,0 +1,181 @@ +/* + 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 General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { mutate } from "swr"; +import { seedSWRCache as seed } from "../testing/swr-cache.js"; +import { TemplateType } from "@gnu-taler/taler-util"; +import { useInventory, useCategories } from "./hooks/useInventory.js"; +import { useWebhooks } from "./hooks/useWebhooks.js"; +import { useTemplates } from "./hooks/useTemplates.js"; +import { useTokenFamilies } from "./hooks/useTokenFamilies.js"; +import { invalidateMerchantCache } from "./cache.js"; +import { useHttpLibForTesting } from "./client.js"; +import { FakeHttpLib, noContent, ok } from "../testing/fake-http.js"; +import { signIn, signOut } from "../stores/session.js"; + +for (const entity of [ + "product", + "webhook", + "template", + "family", + "category", +] as const) { + for (const operation of ["update", "delete", "failure"] as const) { + test(`${entity} ${operation} invalidates dependent caches only after success`, async () => { + const backend = `https://${entity}-${operation}.example.test/`; + const token = "secret-token:test"; + const key = (name: string, id?: string) => [ + name, + backend, + "shop", + token, + ...(id === undefined ? [] : [id]), + ]; + const resource = { + product: "getProductDetails", + webhook: "getWebhookDetails", + template: "getTemplateDetails", + family: "getTokenFamilyDetails", + category: "listProducts", + }[entity]; + const affected = [ + key(resource, entity === "category" ? undefined : "item"), + ]; + if (entity === "product") affected.push(key("listCategories")); + if (entity === "category" && operation === "delete") + affected.push(key("getProductDetails", "item")); + const untouched = [ + key(resource, "other"), + [resource, backend, "other-shop", token, "item"], + [resource, backend, "shop", "other-token", "item"], + [resource, "https://other.example.test/", "shop", token, "item"], + ]; + // No detail view is mounted: revalidation alone cannot remove these snapshots. + for (const k of [...affected, ...untouched]) + await seed(k, { name: "Old" }); + const http = new FakeHttpLib().handle((request) => { + if (request.method === "GET") + return ok({ + slug: "item", + name: "Old", + description: "Old", + kind: "subscription", + valid_after: { t_s: 0 }, + valid_before: { t_s: "never" }, + duration: { d_us: 86400000000 }, + issued: 0, + used: 0, + }); + if (operation === "failure") throw new Error("save rejected"); + return noContent(); + }); + const restoreHttp = useHttpLibForTesting(http); + signIn("shop", token, backend); + const container = document.body.appendChild( + document.createElement("div"), + ); + let run!: () => Promise<unknown>; + function Probe() { + const inventory = useInventory({ enabled: false }); + const webhooks = useWebhooks(); + const templates = useTemplates({ enabled: false }); + const families = useTokenFamilies({ enabled: false }); + const categories = useCategories({ enabled: false }); + const deleting = operation === "delete"; + run = { + product: () => + deleting + ? inventory.deleteProduct("item") + : inventory.updateProduct("item", { + price: "EUR:3", + categories: [1], + }), + webhook: () => + deleting + ? webhooks.deleteWebhook("item") + : webhooks.updateWebhook("item", { + id: "item", + url: "https://new.example.test/", + event: "pay", + }), + template: () => + deleting + ? templates.deleteTemplate("item") + : templates.updateTemplate("item", "New", { + template_type: TemplateType.FIXED_ORDER, + amount: "EUR:3", + }), + family: () => + deleting + ? families.deleteTokenFamily("item") + : families.updateTokenFamily("item", { name: "New" }), + category: () => + deleting + ? categories.deleteCategory(1) + : categories.updateCategory(1, "New"), + }[entity]; + return null; + } + try { + await act(async () => render(<Probe />, container)); + if (operation === "failure") + await assert.rejects(run(), /save rejected/); + else + await act(async () => { + await run(); + }); + for (const k of affected) + assert.deepEqual( + await mutate(k), + operation === "failure" ? { name: "Old" } : undefined, + ); + // A category edit refreshes every product list variant in this session. + for (const k of untouched.filter( + (k) => + !( + entity === "category" && + k[2] === "shop" && + k[3] === token && + k[1] === backend + ), + )) { + assert.deepEqual(await mutate(k), { name: "Old" }); + } + } finally { + act(() => render(null, container)); + container.remove(); + restoreHttp(); + signOut(); + } + }); + } +} + +test("list invalidation clears all cached filters and pages within the session", async () => { + const config = { + rootUrl: new URL("https://lists.example.test/"), + account: "shop", + }; + for (const resource of ["listOrders", "merchant-incoming-transfers"]) { + const keys = ["all", "paid", "refunded"].map((filter) => [ + resource, + config.rootUrl.href, + config.account, + "token", + filter, + ]); + for (const key of keys) await seed(key, ["old"]); + await invalidateMerchantCache(config, "token", resource); + for (const key of keys) assert.equal(await mutate(key), undefined); + } +}); diff --git a/packages/taler-merchant-webui/src/api/cache.ts b/packages/taler-merchant-webui/src/api/cache.ts @@ -0,0 +1,31 @@ +/* + 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 General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import { mutate } from "swr"; +import type { BackendConfig } from "./client.js"; + +/** Clear stale snapshots even when their views are unmounted, and reload active views. */ +export async function invalidateMerchantCache( + config: BackendConfig, + token: string, + resource: string, + id?: string, +): Promise<void> { + await mutate( + (key) => + Array.isArray(key) && + key[0] === resource && + key[1] === config.rootUrl.href && + key[2] === config.account && + key[3] === token && + (id === undefined || key[4] === id), + undefined, + { revalidate: true }, + ); +} diff --git a/packages/taler-merchant-webui/src/api/hooks/useInventory.ts b/packages/taler-merchant-webui/src/api/hooks/useInventory.ts @@ -20,6 +20,7 @@ import { merchantClient } from "../client.js"; import { unwrap, unwrapEmpty } from "../failure.js"; import { session } from "../../stores/session.js"; import type { CategoryItem, ProductItem } from "../../types/domain.js"; +import { invalidateMerchantCache } from "../cache.js"; import { getClientConfig } from "./common.js"; import { remoteResource } from "../contracts.js"; import { useTranslation } from "../../context/translation.js"; @@ -151,6 +152,7 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { }; const res = await client.addProduct(token as AccessToken, body); unwrap(res); + await invalidateMerchantCache(config, token, "listCategories"); await mutate(); }; @@ -170,6 +172,13 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { body, ); unwrapEmpty(res); + await invalidateMerchantCache( + config, + token, + "getProductDetails", + productId, + ); + await invalidateMerchantCache(config, token, "listCategories"); await mutate(); }; @@ -180,6 +189,13 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { force, }); unwrapEmpty(res); + await invalidateMerchantCache( + config, + token, + "getProductDetails", + productId, + ); + await invalidateMerchantCache(config, token, "listCategories"); await mutate(); }; @@ -293,6 +309,7 @@ export function useCategories({ enabled = true }: { enabled?: boolean } = {}) { { name }, ); unwrap(res); + await invalidateMerchantCache(config, token, "listProducts"); await mutate(); }; @@ -304,6 +321,8 @@ export function useCategories({ enabled = true }: { enabled?: boolean } = {}) { String(categoryId), ); unwrap(res); + await invalidateMerchantCache(config, token, "listProducts"); + await invalidateMerchantCache(config, token, "getProductDetails"); await mutate(); }; diff --git a/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts b/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts @@ -24,6 +24,7 @@ import { captureSessionIdentity, session } from "../../stores/session.js"; import { Duration } from "@gnu-taler/taler-util"; import { templatePayUri } from "../../utils/templates.js"; import type { TemplateItem } from "../../types/domain.js"; +import { invalidateMerchantCache } from "../cache.js"; import { getClientConfig } from "./common.js"; import { remoteResource } from "../contracts.js"; import { @@ -133,6 +134,12 @@ export function useTemplates({ enabled = true }: { enabled?: boolean } = {}) { ...templateWriteExtras(extra), }); unwrap(res); + await invalidateMerchantCache( + config, + token, + "getTemplateDetails", + templateId, + ); await mutate(); }; @@ -151,6 +158,12 @@ export function useTemplates({ enabled = true }: { enabled?: boolean } = {}) { const client = merchantClient(config); const res = await client.deleteTemplate(token as AccessToken, templateId); unwrap(res); + await invalidateMerchantCache( + config, + token, + "getTemplateDetails", + templateId, + ); await mutate(); }; diff --git a/packages/taler-merchant-webui/src/api/hooks/useTokenFamilies.ts b/packages/taler-merchant-webui/src/api/hooks/useTokenFamilies.ts @@ -24,6 +24,7 @@ import { import { merchantClient } from "../client.js"; import { unwrap, unwrapEmpty } from "../failure.js"; import { session } from "../../stores/session.js"; +import { invalidateMerchantCache } from "../cache.js"; import { getClientConfig } from "./common.js"; import { remoteResource } from "../contracts.js"; import { mapWithConcurrency } from "../concurrency.js"; @@ -223,6 +224,7 @@ export function useTokenFamilies({ const client = merchantClient(config); const res = await client.deleteTokenFamily(token as AccessToken, slug); unwrapEmpty(res); + await invalidateMerchantCache(config, token, "getTokenFamilyDetails", slug); await mutate(); }; @@ -245,6 +247,7 @@ export function useTokenFamilies({ body, ); unwrap(res); + await invalidateMerchantCache(config, token, "getTokenFamilyDetails", slug); await mutate(); }; diff --git a/packages/taler-merchant-webui/src/api/hooks/useWebhooks.ts b/packages/taler-merchant-webui/src/api/hooks/useWebhooks.ts @@ -20,6 +20,7 @@ import { merchantClient } from "../client.js"; import { unwrap, unwrapEmpty } from "../failure.js"; import { session } from "../../stores/session.js"; import type { WebhookEndpoint } from "../../types/domain.js"; +import { invalidateMerchantCache } from "../cache.js"; import { getClientConfig } from "./common.js"; import { remoteResource } from "../contracts.js"; import { mapWithConcurrency } from "../concurrency.js"; @@ -107,6 +108,12 @@ export function useWebhooks() { webhookDetails(wh), ); unwrapEmpty(res); + await invalidateMerchantCache( + config, + token, + "getWebhookDetails", + webhookId, + ); await mutate(); }; @@ -115,6 +122,12 @@ export function useWebhooks() { const client = merchantClient(config); const res = await client.deleteWebhook(token as AccessToken, webhookId); unwrapEmpty(res); + await invalidateMerchantCache( + config, + token, + "getWebhookDetails", + webhookId, + ); await mutate(); }; diff --git a/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx @@ -26,11 +26,7 @@ import { CreateProductScreen } from "../screens/CreateProductScreen.js"; export function CreateProductRoute({ editId }: { editId?: string }): VNode { const { createProduct, updateProduct } = useInventory(); - const { - product, - resource: productResource, - mutate: refreshProduct, - } = useProductDetails(editId); + const { product, resource: productResource } = useProductDetails(editId); const { categories, resource: categoriesResource, @@ -59,11 +55,7 @@ export function CreateProductRoute({ editId }: { editId?: string }): VNode { configResource={configResource} payoutCurrencies={payoutCurrencies} onCreateProduct={createProduct} - onUpdateProduct={async (id, detail) => { - await updateProduct(id, detail); - // The next editor draft must start from the saved product details. - await refreshProduct(); - }} + onUpdateProduct={updateProduct} onCreateCategory={createCategory} /> ); diff --git a/packages/taler-merchant-webui/src/testing/swr-cache.ts b/packages/taler-merchant-webui/src/testing/swr-cache.ts @@ -0,0 +1,23 @@ +/* + 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 General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + */ + +import { mutate, unstable_serialize } from "swr"; +import { cache } from "swr/_internal"; + +// The Node renderer disables React layout effects. Supply the original key +// metadata that SWR records when a real browser mounts a cache reader. +export async function seedSWRCache( + key: unknown[], + value: unknown, +): Promise<void> { + await mutate(key, value, false); + const serialized = unstable_serialize(key); + const entry = { ...cache.get(serialized), _k: key }; + cache.set(serialized, entry); +}