feat(catalog): add reviewed bulk edits and complete category duplication
This commit is contained in:
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
Reference in new issue
Block a user