feat(catalog): add reviewed bulk edits and complete category duplication

This commit is contained in:
Simo committed 2026-09-06 20:25:19 +02:00
1 parent 0bedc04692
commit 55a47949f1
26 files changed
+2504 -85

No files matched your search

@@ -0,0 +1,89 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
permission: vi.fn(),
preview: vi.fn(),
destinations: vi.fn(),
apply: vi.fn(),
export: vi.fn(),
send: vi.fn(),
audit: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_VIEW: "view", CATALOG_EDIT: "edit" },
}));
vi.mock("@/features/catalog/server/bulk-offers", () => ({
previewBulkOffersCommand: state.preview,
listBulkOfferDestinationsCommand: state.destinations,
applyBulkOffersCommand: state.apply,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: state.export,
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.send,
}));
vi.mock("@/lib/services/audit", () => ({ logAudit: state.audit }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: state.refresh }));
import {
applyBulkOffers,
getBulkOfferDestinations,
previewBulkOffers,
} from "@/actions/catalog-bulk";
const input = { ids: [1], changes: { pageId: 2 } };
beforeEach(() => {
vi.clearAllMocks();
state.permission.mockResolvedValue({ id: 1 });
state.export.mockImplementation((fn) => fn());
state.apply.mockResolvedValue({ changedCount: 1 });
state.preview.mockResolvedValue({ fingerprint: "x" });
state.audit.mockResolvedValue(undefined);
});
it("gates read preview without scheduling any mutation", async () => {
await previewBulkOffers(input);
expect(state.permission).toHaveBeenCalledWith("view");
expect(state.export).not.toHaveBeenCalled();
expect(state.apply).not.toHaveBeenCalled();
});
it("gates edits before database and sends one update after commit", async () => {
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
expect(state.permission).toHaveBeenCalledWith("edit");
expect(state.export).toHaveBeenCalledTimes(1);
expect(state.send).toHaveBeenCalledTimes(1);
expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan(
state.send.mock.invocationCallOrder[0],
);
});
it("stops denied permission before export or data access", async () => {
state.permission.mockRejectedValueOnce(Error("Denied"));
await expect(applyBulkOffers(input, "x")).rejects.toThrow("Denied");
expect(state.export).not.toHaveBeenCalled();
});
it("does not notify hotel when a transaction fails", async () => {
state.apply.mockRejectedValueOnce(Error("failed"));
expect((await applyBulkOffers(input, "x")).ok).toBe(false);
expect(state.send).not.toHaveBeenCalled();
expect(state.audit).not.toHaveBeenCalled();
});
it("does not report a committed edit as failed on audit failure", async () => {
state.audit.mockRejectedValueOnce(Error("log failed"));
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
expect(state.send).toHaveBeenCalledTimes(1);
});
it("loads destination choices with view permission only", async () => {
state.destinations.mockResolvedValueOnce({
pages: [{ id: 9, caption: "Target" }],
});
expect(await getBulkOfferDestinations()).toEqual({
ok: true,
data: { pages: [{ id: 9, caption: "Target" }] },
});
expect(state.permission).toHaveBeenCalledWith("view");
expect(state.export).not.toHaveBeenCalled();
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,78 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
failAudit: false,
permission: vi.fn(),
export: vi.fn(async (fn: () => unknown) => fn()),
sync: vi.fn(async () => ({ sent: true })),
audit: vi.fn(async () => {
if (state.failAudit) throw Error("Audit unavailable");
}),
command: vi.fn(async () => ({ id: 44, pages: 2, offers: 3 })),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: async (permission: string) => {
state.permission(permission);
return { id: 7 };
},
}));
vi.mock("@/features/catalog/server/duplicate-commands", () => ({
duplicateCategoryCommand: state.command,
previewDuplicateCommand: vi.fn(async () => ({ pages: 2 })),
duplicateDestinationsCommand: vi.fn(async () => []),
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.sync,
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: state.export,
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: state.audit,
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
import {
applyCatalogDuplicate,
previewCatalogDuplicate,
} from "@/actions/catalog-duplicate";
import { PERMS } from "@/lib/permissions";
const input = {
kind: "normal" as const,
sourceId: 1,
parentId: -1,
name: "Copy",
fingerprint: "a".repeat(64),
pages: 2,
offers: 3,
destination: "Root",
};
beforeEach(() => {
vi.clearAllMocks();
state.failAudit = false;
});
it("requires view for preview and edit for duplication, exports and syncs once", async () => {
await previewCatalogDuplicate(input);
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
expect(state.export).not.toHaveBeenCalled();
const result = await applyCatalogDuplicate(input);
expect(result.ok).toBe(true);
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
expect(state.command).toHaveBeenCalledOnce();
expect(state.export).toHaveBeenCalledOnce();
expect(state.sync).toHaveBeenCalledOnce();
expect(state.audit).toHaveBeenCalledOnce();
});
it("preserves successful result after commit when audit logging fails", async () => {
state.failAudit = true;
await expect(applyCatalogDuplicate(input)).resolves.toMatchObject({
ok: true,
data: { id: 44 },
});
expect(state.command).toHaveBeenCalledOnce();
});
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_EDIT: "catalog.edit", CATALOG_VIEW: "catalog.view" },
}));
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff