Files
EpicNext-Cms/src/actions/catalog-bc.test.ts
T
openhands e0efbef30d
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 28s
CI / tests-unit (push) Successful in 1m39s
CI / tests-integration (push) Successful in 1m42s
CI / tests-ui (push) Successful in 2m23s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m6s
fix(catalog): make the Builder Club catalog read and write its own offers
The previous commit taught bulk editing and delete-with-restore about the BC
catalog. Neither actually worked, and one of them was destructive.

`catalog_items_bc` has six columns: id, item_ids, page_id, catalog_name,
order_number, extradata. There is no price, points, currency, offer_id, limit or
membership column on it. The bulk path read and wrote columns that do not
exist, and the UPDATE was aimed at catalog_items while the SELECT came from
catalog_items_bc — so a BC category move wrote into the normal catalog. Two
tests now pin that pairing: reads and writes have to stay in the same table.

Underneath it the BC table was never being read at all. The inline editor
fetched `/api/admin/catalog/items?pageId=N` without the catalog, so opening a BC
category showed the normal catalog's offers, and the route selected BC rows
directly instead of going through the loader, skipping the furni enrichment the
table needs to render anything but a bare caption. Both catalogs now take the
same path, and the catalog is in the fetch callback's dependencies — without
that, a switch keeps reading the previous catalog's rows through a stale
closure.

Because a BC offer has no price, the editor no longer offers one. The server
refuses price, points and currency changes with a readable message instead of
letting them reach the database as an unknown-column error, and a BC bulk edit
is what it can actually be: a category move.

BC deletions also went through a bare DELETE, which made them the one catalog
mutation with no way back. They now keep their rows and hand back a restoreId
like the normal ones. The catalog is recorded in the audit target rather than
in the payload, so a restore can never put a BC row into the normal offers
table.
2026-09-30 19:17:20 +02:00

407 lines
12 KiB
TypeScript

// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
deletes: [] as { table: unknown; where: unknown }[],
deleteError: null as Error | null,
}));
const mockRequirePermission = vi.hoisted(() => vi.fn());
vi.mock("@/lib/admin/guard", () => ({
requirePermission: mockRequirePermission,
}));
vi.mock("@/lib/permissions", async () => {
const { PERMS } = await import("@/lib/permission-slugs");
return { PERMS };
});
vi.mock("@/lib/auth", () => ({ auth: vi.fn(), handlers: {} }));
const mockRevalidatePath = vi.hoisted(() => vi.fn());
vi.mock("next/cache", () => ({
revalidatePath: mockRevalidatePath,
unstable_cache: (fn: unknown) => fn,
}));
const mockCatalogFailure = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/errors", () => ({
catalogFailure: mockCatalogFailure,
}));
const mockUpdateBcOfferCommand = vi.hoisted(() => vi.fn());
const mockCreateBcOfferCommand = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/offer-commands", () => ({
updateBcOfferCommand: mockUpdateBcOfferCommand,
createBcOfferCommand: mockCreateBcOfferCommand,
}));
const mockUpdatePageCommand = vi.hoisted(() => vi.fn());
const mockCreatePageCommand = vi.hoisted(() => vi.fn());
const mockDeletePageCommand = vi.hoisted(() => vi.fn());
const mockReorderPagesCommand = vi.hoisted(() => vi.fn());
const mockTogglePageCommand = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/page-commands", () => ({
updatePageCommand: mockUpdatePageCommand,
createPageCommand: mockCreatePageCommand,
deletePageCommand: mockDeletePageCommand,
reorderPagesCommand: mockReorderPagesCommand,
togglePageCommand: mockTogglePageCommand,
}));
const mockDeleteCatalogItemsCommand = vi.hoisted(() =>
vi.fn(async () => ({ deleted: 1, restoreId: 55 })),
);
vi.mock("@/features/catalog/server/item-deletes", () => ({
deleteCatalogItemsCommand: mockDeleteCatalogItemsCommand,
}));
const mockSendCatalogUpdate = vi.hoisted(() => vi.fn());
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: mockSendCatalogUpdate,
}));
const mockWithCatalogExport = vi.hoisted(() => vi.fn());
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: mockWithCatalogExport,
catalogExportEnabled: () => true,
}));
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: mockLogStaffActivity,
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
return {
...schema,
db: {
delete: (table: unknown) => ({
where: (where: unknown) => {
if (state.deleteError) {
const e = state.deleteError;
state.deleteError = null;
throw e;
}
state.deletes.push({ table, where });
return [{ affectedRows: 1 }];
},
}),
},
};
});
import {
createBcItem,
createBcPage,
deleteBcItem,
deleteBcTreePage,
reorderBcCatalogPages,
reorderBcTreePage,
toggleBcPage,
updateBcItem,
updateBcPage,
} from "./catalog-bc";
const staff = { id: 3, rank: 6, username: "cat" };
beforeEach(() => {
vi.clearAllMocks();
mockRequirePermission.mockResolvedValue(staff as never);
state.deletes = [];
state.deleteError = null;
mockWithCatalogExport.mockImplementation(
async (fn: () => unknown) => await fn(),
);
mockCatalogFailure.mockReturnValue({
message: "Catalog op failed",
status: 400,
});
mockCreateBcOfferCommand.mockResolvedValue(501);
mockCreatePageCommand.mockResolvedValue(201);
mockSendCatalogUpdate.mockResolvedValue({});
mockLogStaffActivity.mockResolvedValue(undefined);
mockUpdateBcOfferCommand.mockResolvedValue({});
mockUpdatePageCommand.mockResolvedValue({});
mockDeletePageCommand.mockResolvedValue({});
mockReorderPagesCommand.mockResolvedValue({});
mockTogglePageCommand.mockResolvedValue({});
});
describe("updateBcPage", () => {
it("updates only allowed page fields and logs activity", async () => {
const result = await updateBcPage({
id: 1,
caption: "Renamed",
pageHeadline: "H",
foo: "filtered",
expected: { caption: "Old" },
});
expect(result).toEqual({ ok: true });
expect(mockUpdatePageCommand).toHaveBeenCalledWith(
"bc",
1,
{ caption: "Renamed", pageHeadline: "H" },
{ caption: "Old" },
staff.id,
);
expect(mockSendCatalogUpdate).toHaveBeenCalled();
expect(mockLogStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "bc_page_update",
description: "Updated BC catalog page #1",
}),
);
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/admin/catalog/builder-club",
);
});
it("rejects when no allowed field is present", async () => {
const result = await updateBcPage({ id: 1 });
expect(result).toEqual({
ok: false,
error: "No valid fields to update",
});
expect(mockUpdatePageCommand).not.toHaveBeenCalled();
});
it("maps command failures through catalogFailure", async () => {
mockUpdatePageCommand.mockRejectedValueOnce(new Error("boom"));
const result = await updateBcPage({ id: 1, caption: "X" });
expect(result).toEqual({ ok: false, error: "Catalog op failed" });
expect(mockCatalogFailure).toHaveBeenCalled();
});
it("bubbles up permission errors", async () => {
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
await expect(updateBcPage({ id: 1, caption: "X" })).rejects.toThrow(
"denied",
);
});
});
describe("deleteBcItem", () => {
it("deletes through the restorable path and logs activity", async () => {
const result = await deleteBcItem({ id: 7 });
expect(result).toEqual({
ok: true,
data: { deleted: 1, restoreId: 55 },
});
// Not a raw DELETE: BC rows are kept so the delete can be undone, the same
// as the normal catalog.
expect(mockDeleteCatalogItemsCommand).toHaveBeenCalledWith(
[7],
expect.anything(),
undefined,
"bc",
);
expect(state.deletes).toHaveLength(0);
expect(mockLogStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "bc_item_delete",
description: "Deleted BC catalog offer #7",
}),
);
expect(mockSendCatalogUpdate).toHaveBeenCalled();
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/admin/catalog/builder-club",
);
});
it("reports a failed delete instead of pretending it worked", async () => {
mockDeleteCatalogItemsCommand.mockRejectedValueOnce(Error("db down"));
mockCatalogFailure.mockReturnValueOnce({ message: "Delete failed" });
const result = await deleteBcItem({ id: 7 });
expect(result).toEqual({ ok: false, error: "Delete failed" });
expect(mockSendCatalogUpdate).not.toHaveBeenCalled();
});
});
describe("updateBcItem", () => {
it("updates the offer with only allowed keys", async () => {
const result = await updateBcItem({
id: 12,
catalogName: "New name",
orderNumber: 4,
extradata: "ed",
});
expect(result).toEqual({ ok: true });
expect(mockUpdateBcOfferCommand).toHaveBeenCalledWith(12, {
catalogName: "New name",
orderNumber: 4,
extradata: "ed",
});
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/admin/catalog/builder-club",
);
});
it("rejects when no allowed key is present", async () => {
const result = await updateBcItem({ id: 12 });
expect(result).toEqual({
ok: false,
error: "No valid fields to update",
});
});
it("maps command failures through catalogFailure", async () => {
mockUpdateBcOfferCommand.mockRejectedValueOnce(new Error("boom"));
const result = await updateBcItem({ id: 12, catalogName: "X" });
expect(result).toEqual({ ok: false, error: "Catalog op failed" });
});
});
describe("createBcItem", () => {
it("creates the offer and returns its id", async () => {
const result = await createBcItem({
pageId: 8,
itemIds: "1;2",
catalogName: "Bundle",
orderNumber: 1,
extradata: "",
});
expect(result).toEqual({ ok: true, data: { id: 501 } });
expect(mockCreateBcOfferCommand).toHaveBeenCalledWith({
pageId: 8,
itemIds: "1;2",
catalogName: "Bundle",
orderNumber: 1,
extradata: "",
});
expect(mockLogStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "bc_item_create",
description: "Created BC catalog item #501",
}),
);
});
});
describe("toggleBcPage", () => {
it("toggles the requested field", async () => {
const result = await toggleBcPage({ id: 2, field: "visible" });
expect(result).toEqual({ ok: true });
expect(mockTogglePageCommand).toHaveBeenCalledWith(
"bc",
2,
"visible",
staff.id,
);
expect(mockSendCatalogUpdate).toHaveBeenCalled();
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/admin/catalog/builder-club",
);
});
});
describe("createBcPage", () => {
it("creates a page with default layout fields", async () => {
const result = await createBcPage({ caption: "Page", parentId: -1 });
expect(result).toEqual({ ok: true, data: { id: 201 } });
expect(mockCreatePageCommand).toHaveBeenCalledWith("bc", {
caption: "Page",
parentId: -1,
pageLayout: "default_3x3",
iconColor: 0,
iconImage: 0,
orderNum: 0,
visible: "1",
enabled: "1",
pageHeadline: "",
pageTeaser: "",
});
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
expect(mockLogStaffActivity).toHaveBeenCalledWith(
expect.objectContaining({
action: "bc_page_create",
description: 'Created BC catalog page "Page"',
}),
);
});
it("passes explicit values through", async () => {
await createBcPage({
caption: "Page",
parentId: 1,
pageLayout: "default_3x3",
iconColor: 3,
iconImage: 2,
orderNum: 9,
enabled: "0",
visible: "0",
});
expect(mockCreatePageCommand).toHaveBeenCalledWith("bc", {
caption: "Page",
parentId: 1,
pageLayout: "default_3x3",
iconColor: 3,
iconImage: 2,
orderNum: 9,
visible: "0",
enabled: "0",
pageHeadline: "",
pageTeaser: "",
});
});
});
describe("reorderBcTreePage", () => {
it("reparents with new order number", async () => {
const result = await reorderBcTreePage({
pageId: 5,
newParentId: 3,
newOrderNum: 2,
});
expect(result).toEqual({ ok: true, data: {} });
expect(mockUpdatePageCommand).toHaveBeenCalledWith("bc", 5, {
parentId: 3,
orderNum: 2,
});
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/admin/catalog/builder-club",
);
});
it("allows dropping to the root (undefined parent)", async () => {
await reorderBcTreePage({ pageId: 5, newOrderNum: 1 });
expect(mockUpdatePageCommand).toHaveBeenCalledWith("bc", 5, {
parentId: undefined,
orderNum: 1,
});
});
});
describe("deleteBcTreePage", () => {
it("deletes with cascade mode", async () => {
const result = await deleteBcTreePage({ pageId: 4, mode: "cascade" });
expect(result).toEqual({ ok: true, data: {} });
expect(mockDeletePageCommand).toHaveBeenCalledWith("bc", 4, "cascade");
expect(mockSendCatalogUpdate).toHaveBeenCalled();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
});
it("deletes with reparent mode", async () => {
await deleteBcTreePage({ pageId: 4, mode: "reparent" });
expect(mockDeletePageCommand).toHaveBeenCalledWith("bc", 4, "reparent");
});
});
describe("reorderBcCatalogPages", () => {
it("reorders pages via the command", async () => {
const input = { parentId: 1, ids: [2, 3], expectedIds: [3, 2] };
const result = await reorderBcCatalogPages(input);
expect(result).toEqual({ ok: true, data: {} });
expect(mockReorderPagesCommand).toHaveBeenCalledWith("bc", input);
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
expect(mockRevalidatePath).toHaveBeenCalledWith(
"/admin/catalog/builder-club",
);
});
});