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();
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
catalogName: "Chair",
|
||||
pageId: 4,
|
||||
costCredits: 3,
|
||||
costPoints: 10,
|
||||
pointsType: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
catalogName: "Table",
|
||||
pageId: 4,
|
||||
costCredits: 8,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{ id: 4, caption: "Source" },
|
||||
{ id: 9, caption: "Target" },
|
||||
],
|
||||
queries: [] as string[],
|
||||
writes: 0,
|
||||
failAt: 0,
|
||||
commits: 0,
|
||||
rollbacks: 0,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
execute: async () => [state.pages, []],
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const before = state.writes;
|
||||
try {
|
||||
const result = await fn({
|
||||
execute: async (query: SQL) => {
|
||||
const text = new MySqlDialect().sqlToQuery(query).sql;
|
||||
state.queries.push(text);
|
||||
if (text.startsWith("SELECT"))
|
||||
return [
|
||||
text.includes("catalog_pages") ? state.pages : state.rows,
|
||||
[],
|
||||
];
|
||||
state.writes++;
|
||||
if (state.writes === state.failAt) throw Error("write failed");
|
||||
return [{ affectedRows: 1 }, []];
|
||||
},
|
||||
});
|
||||
state.commits++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.writes = before;
|
||||
state.rollbacks++;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
applyBulkOffersCommand,
|
||||
listBulkOfferDestinationsCommand,
|
||||
previewBulkOffersCommand,
|
||||
} from "./bulk-offers";
|
||||
|
||||
const input = {
|
||||
ids: [1, 2],
|
||||
changes: { costCredits: { mode: "add" as const, value: 1 }, pageId: 9 },
|
||||
};
|
||||
beforeEach(() => {
|
||||
state.rows = [
|
||||
{
|
||||
id: 1,
|
||||
catalogName: "Chair",
|
||||
pageId: 4,
|
||||
costCredits: 3,
|
||||
costPoints: 10,
|
||||
pointsType: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
catalogName: "Table",
|
||||
pageId: 4,
|
||||
costCredits: 8,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
},
|
||||
];
|
||||
state.pages = [
|
||||
{ id: 4, caption: "Source" },
|
||||
{ id: 9, caption: "Target" },
|
||||
];
|
||||
state.queries = [];
|
||||
state.writes = 0;
|
||||
state.failAt = 0;
|
||||
state.commits = 0;
|
||||
state.rollbacks = 0;
|
||||
});
|
||||
it("previews all changes without a write", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
expect(p.changedCount).toBe(2);
|
||||
expect(p.rows[0].after).toEqual({
|
||||
pageId: 9,
|
||||
costCredits: 4,
|
||||
costPoints: 10,
|
||||
pointsType: 5,
|
||||
});
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("locks pages before offers and commits the entire batch", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.queries = [];
|
||||
await applyBulkOffersCommand(input, p.fingerprint);
|
||||
expect(state.writes).toBe(2);
|
||||
const locks = state.queries.filter((q) => q.includes("FOR UPDATE"));
|
||||
expect(locks[0]).toContain("catalog_pages");
|
||||
expect(locks[1]).toContain("catalog_items");
|
||||
expect(
|
||||
state.queries
|
||||
.filter((q) => q.startsWith("UPDATE"))
|
||||
.every((q) => !q.includes("cost_points")),
|
||||
).toBe(true);
|
||||
});
|
||||
it("refuses stale prices and changed intent before writing", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.rows[0].costCredits = 100;
|
||||
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
|
||||
/changed/i,
|
||||
);
|
||||
expect(state.writes).toBe(0);
|
||||
state.rows[0].costCredits = 3;
|
||||
await expect(
|
||||
applyBulkOffersCommand({ ...input, changes: { pageId: 9 } }, p.fingerprint),
|
||||
).rejects.toThrow(/changed/i);
|
||||
});
|
||||
it("rolls back earlier updates if a later update fails", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.failAt = 2;
|
||||
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
|
||||
"write failed",
|
||||
);
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects missing offers and destinations", async () => {
|
||||
state.rows.pop();
|
||||
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
|
||||
state.rows.push({
|
||||
id: 2,
|
||||
catalogName: "Table",
|
||||
pageId: 4,
|
||||
costCredits: 8,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
});
|
||||
state.pages.pop();
|
||||
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
|
||||
});
|
||||
it("rejects invalid resulting price before any update", async () => {
|
||||
await expect(
|
||||
previewBulkOffersCommand({
|
||||
ids: [1, 2],
|
||||
changes: { costCredits: { mode: "add", value: -5 } },
|
||||
}),
|
||||
).rejects.toThrow(/negative/i);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects renamed destination after preview", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.pages[1].caption = "Different";
|
||||
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
|
||||
/changed/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not silently truncate destination choices", async () => {
|
||||
state.pages = Array.from({ length: 5001 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
caption: `Category ${i + 1}`,
|
||||
}));
|
||||
await expect(listBulkOfferDestinationsCommand()).rejects.toThrow(/5000/);
|
||||
});
|
||||
it("does not update offers whose chosen values already match", async () => {
|
||||
const request = { ids: [1, 2], changes: { pageId: 4 } };
|
||||
const p = await previewBulkOffersCommand(request);
|
||||
expect(p.changedCount).toBe(0);
|
||||
await applyBulkOffersCommand(request, p.fingerprint);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type BulkOfferInput,
|
||||
type BulkOfferPreview,
|
||||
type BulkOfferValues,
|
||||
bulkOfferInputSchema,
|
||||
projectBulkOffer,
|
||||
} from "../domain/bulk-offers";
|
||||
import {
|
||||
CatalogConflict,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
} from "../domain/hierarchy";
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
type OfferRow = BulkOfferValues & { id: number; catalogName: string };
|
||||
async function readOffers(
|
||||
tx: Tx,
|
||||
ids: number[],
|
||||
lock = false,
|
||||
): Promise<OfferRow[]> {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
|
||||
);
|
||||
const offers = (rows as unknown as OfferRow[]).map((row) => ({
|
||||
...row,
|
||||
id: Number(row.id),
|
||||
pageId: Number(row.pageId),
|
||||
costCredits: Number(row.costCredits),
|
||||
costPoints: Number(row.costPoints),
|
||||
pointsType: Number(row.pointsType),
|
||||
}));
|
||||
if (
|
||||
offers.length !== ids.length ||
|
||||
ids.some((id) => !offers.some((row) => row.id === id))
|
||||
)
|
||||
throw new CatalogNotFound(
|
||||
"Selected catalog offer not found. Reload the selection.",
|
||||
);
|
||||
return offers.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
async function readPages(
|
||||
tx: Tx,
|
||||
offers: OfferRow[],
|
||||
input: BulkOfferInput,
|
||||
lock = false,
|
||||
) {
|
||||
const ids = [
|
||||
...new Set([
|
||||
...offers.map((row) => row.pageId),
|
||||
...(input.changes.pageId ? [input.changes.pageId] : []),
|
||||
]),
|
||||
].sort((a, b) => a - b);
|
||||
if (ids.some((id) => !Number.isInteger(id) || id <= 0))
|
||||
throw new CatalogInputError("An offer has an invalid category reference");
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
|
||||
);
|
||||
const pages = (rows as unknown as { id: number; caption: string }[])
|
||||
.filter((row) => ids.includes(Number(row.id)))
|
||||
.map((row) => ({ id: Number(row.id), caption: row.caption }))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
if (pages.length !== ids.length)
|
||||
throw new CatalogNotFound("Source or destination category not found");
|
||||
return pages;
|
||||
}
|
||||
function preview(
|
||||
input: BulkOfferInput,
|
||||
offers: OfferRow[],
|
||||
pages: BulkOfferPreview["pages"],
|
||||
): BulkOfferPreview {
|
||||
const rows = offers.map(({ id, catalogName, ...before }) => ({
|
||||
id,
|
||||
catalogName,
|
||||
before,
|
||||
after: projectBulkOffer(before, input.changes),
|
||||
}));
|
||||
const changedCount = rows.filter((row) =>
|
||||
Object.keys(input.changes).some(
|
||||
(key) =>
|
||||
row.before[key as keyof BulkOfferValues] !==
|
||||
row.after[key as keyof BulkOfferValues],
|
||||
),
|
||||
).length;
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
input: { ...input, ids: [...input.ids].sort((a, b) => a - b) },
|
||||
rows,
|
||||
pages,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
return { rows, pages, changedCount, fingerprint };
|
||||
}
|
||||
export async function previewBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
): Promise<BulkOfferPreview> {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
return db.transaction(async (tx) => {
|
||||
const offers = await readOffers(tx, input.ids);
|
||||
return preview(input, offers, await readPages(tx, offers, input));
|
||||
});
|
||||
}
|
||||
export async function applyBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
) {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
throw new CatalogInputError("A valid preview is required");
|
||||
return db.transaction(async (tx) => {
|
||||
// Read references, then lock pages before offers to match category structural commands.
|
||||
const initial = await readOffers(tx, input.ids);
|
||||
const pages = await readPages(tx, initial, input, true);
|
||||
const offers = await readOffers(tx, input.ids, true);
|
||||
if (offers.some((row) => !pages.some((page) => page.id === row.pageId)))
|
||||
throw new CatalogConflict("The selection changed. Refresh the preview.");
|
||||
const result = preview(input, offers, pages);
|
||||
if (result.fingerprint !== fingerprint)
|
||||
throw new CatalogConflict(
|
||||
"The selection or destination changed. Refresh the preview before applying.",
|
||||
);
|
||||
const columns = {
|
||||
pageId: "page_id",
|
||||
costCredits: "cost_credits",
|
||||
costPoints: "cost_points",
|
||||
pointsType: "points_type",
|
||||
};
|
||||
for (const row of result.rows) {
|
||||
const changes = (
|
||||
Object.keys(input.changes) as Array<keyof BulkOfferValues>
|
||||
).filter((key) => row.before[key] !== row.after[key]);
|
||||
if (!changes.length) continue;
|
||||
await tx.execute(
|
||||
sql`UPDATE catalog_items SET ${sql.join(
|
||||
changes.map(
|
||||
(key) =>
|
||||
sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`,
|
||||
),
|
||||
sql`, `,
|
||||
)} WHERE id=${row.id}`,
|
||||
);
|
||||
}
|
||||
return { changedCount: result.changedCount };
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBulkOfferDestinationsCommand() {
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`,
|
||||
);
|
||||
const pages = (rows as unknown as Array<{ id: number; caption: string }>).map(
|
||||
(row) => ({ id: Number(row.id), caption: row.caption }),
|
||||
);
|
||||
if (pages.length > 5000)
|
||||
throw new CatalogInputError(
|
||||
"The catalog has more than 5000 categories. Use bulk editing from a category instead.",
|
||||
);
|
||||
return { pages };
|
||||
}
|
||||
@@ -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" },
|
||||
}));
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pages: [
|
||||
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
|
||||
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
|
||||
],
|
||||
offers: [
|
||||
{
|
||||
id: 10,
|
||||
page_id: 2,
|
||||
item_ids: "8;8",
|
||||
cost_credits: 23,
|
||||
limited_stack: 10,
|
||||
offer_id: 10,
|
||||
},
|
||||
],
|
||||
bases: [{ id: 8 }],
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
writes: 0,
|
||||
failAt: 0,
|
||||
commits: 0,
|
||||
rollbacks: 0,
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-import", () => ({
|
||||
allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(500),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const saved = state.writes;
|
||||
try {
|
||||
const result = await fn({
|
||||
execute: async (query: SQL) => {
|
||||
const compiled = new MySqlDialect().sqlToQuery(query);
|
||||
state.queries.push(compiled);
|
||||
if (compiled.sql.startsWith("SELECT"))
|
||||
return [
|
||||
compiled.sql.includes("catalog_pages")
|
||||
? state.pages
|
||||
: compiled.sql.includes("items_base")
|
||||
? state.bases
|
||||
: state.offers,
|
||||
[],
|
||||
];
|
||||
state.writes++;
|
||||
if (state.writes === state.failAt) throw Error("Insert failed");
|
||||
return [
|
||||
{
|
||||
insertId: compiled.sql.includes("`catalog_items`")
|
||||
? 0
|
||||
: 100 + state.writes,
|
||||
},
|
||||
[],
|
||||
];
|
||||
},
|
||||
});
|
||||
state.commits++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.writes = saved;
|
||||
state.rollbacks++;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
duplicateCategoryCommand,
|
||||
previewDuplicateCommand,
|
||||
} from "./duplicate-commands";
|
||||
|
||||
const input = {
|
||||
kind: "normal" as const,
|
||||
sourceId: 1,
|
||||
parentId: -1,
|
||||
name: "Copy",
|
||||
};
|
||||
beforeEach(() => {
|
||||
state.pages = [
|
||||
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
|
||||
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
|
||||
];
|
||||
state.offers = [
|
||||
{
|
||||
id: 10,
|
||||
page_id: 2,
|
||||
item_ids: "8;8",
|
||||
cost_credits: 23,
|
||||
limited_stack: 10,
|
||||
offer_id: 10,
|
||||
},
|
||||
];
|
||||
state.bases = [{ id: 8 }];
|
||||
state.queries = [];
|
||||
state.writes = 0;
|
||||
state.failAt = 0;
|
||||
state.commits = 0;
|
||||
state.rollbacks = 0;
|
||||
});
|
||||
describe("atomic subtree duplication", () => {
|
||||
it("previews without writes and copies descendants, offers and internal references", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
expect(preview).toMatchObject({ pages: 2, offers: 1 });
|
||||
expect(state.writes).toBe(0);
|
||||
state.queries = [];
|
||||
await duplicateCategoryCommand(preview);
|
||||
const queries = state.queries;
|
||||
expect(queries.slice(0, 3).map((q) => q.sql)).toEqual([
|
||||
expect.stringContaining("catalog_pages"),
|
||||
expect.stringContaining("catalog_items"),
|
||||
expect.stringContaining("items_base"),
|
||||
]);
|
||||
expect(queries.slice(0, 3).every((q) => q.sql.endsWith("FOR UPDATE"))).toBe(
|
||||
true,
|
||||
);
|
||||
const inserts = queries.filter((q) => q.sql.startsWith("INSERT"));
|
||||
expect(inserts).toHaveLength(3);
|
||||
expect(inserts[0].params).toContain("0");
|
||||
expect(inserts[1].params).toContain(101);
|
||||
expect(inserts[1].params).toContain("1");
|
||||
expect(inserts[2].params.filter((p) => p === 500)).toHaveLength(2);
|
||||
expect(inserts[2].params).toEqual(
|
||||
expect.arrayContaining(["102", "8;8", 23, 10, 500]),
|
||||
);
|
||||
expect(queries.find((q) => q.sql.startsWith("UPDATE"))?.params).toEqual([
|
||||
"102",
|
||||
101,
|
||||
]);
|
||||
expect(state.pages[0].caption).toBe("Source");
|
||||
});
|
||||
it("detects stale source fields before any insert", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
state.offers[0].cost_credits = 99;
|
||||
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rolls back pages if an offer insert fails", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
state.failAt = 4;
|
||||
await expect(duplicateCategoryCommand(preview)).rejects.toThrow(
|
||||
"Insert failed",
|
||||
);
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects missing furniture without writes", async () => {
|
||||
state.bases = [];
|
||||
await expect(previewDuplicateCommand(input)).rejects.toThrow("Furniture");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("copies BC with database-generated IDs and no added currency columns", async () => {
|
||||
const preview = await previewDuplicateCommand({ ...input, kind: "bc" });
|
||||
state.queries = [];
|
||||
await duplicateCategoryCommand(preview);
|
||||
const inserts = state.queries.filter((q) => q.sql.startsWith("INSERT"));
|
||||
expect(inserts[2].sql).toContain("catalog_items_bc");
|
||||
expect(inserts[2].sql).not.toMatch(/`id`=/);
|
||||
expect(inserts[0].sql).not.toContain("caption_save");
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates the preview after the new root appears among destination siblings", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
const result = await duplicateCategoryCommand(preview);
|
||||
state.pages.push({
|
||||
id: result.id,
|
||||
parent_id: 0,
|
||||
caption: "Copy",
|
||||
enabled: "0",
|
||||
includes: "",
|
||||
});
|
||||
const writes = state.writes;
|
||||
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
|
||||
expect(state.writes).toBe(writes);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import {
|
||||
type DuplicateInput,
|
||||
type DuplicatePreview,
|
||||
type DuplicateRow,
|
||||
duplicateSubtree,
|
||||
remapIncludes,
|
||||
} from "../domain/duplicate";
|
||||
import { CatalogConflict, CatalogInputError } from "../domain/hierarchy";
|
||||
import { parseFurnitureIds } from "../domain/offer-input";
|
||||
|
||||
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
const schema = z.object({
|
||||
kind: z.enum(["normal", "bc"]),
|
||||
sourceId: z.number().int().positive(),
|
||||
parentId: z.number().int().min(-1),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
});
|
||||
function table(kind: DuplicateInput["kind"], offers = false) {
|
||||
return sql.identifier(
|
||||
`catalog_${offers ? "items" : "pages"}${kind === "bc" ? "_bc" : ""}`,
|
||||
);
|
||||
}
|
||||
async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
|
||||
const suffix = lock ? sql` FOR UPDATE` : sql``;
|
||||
const [rawPages] = await tx.execute(
|
||||
sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`,
|
||||
);
|
||||
const pages = rawPages as unknown as DuplicateRow[];
|
||||
if (pages.length > 20000)
|
||||
throw new CatalogInputError(
|
||||
"Catalog exceeds duplication safety limit (20000 categories)",
|
||||
);
|
||||
const subtree = duplicateSubtree(pages, input.sourceId, input.parentId);
|
||||
const ids = subtree.map((p) => Number(p.id));
|
||||
const existing = new Set(pages.map((p) => Number(p.id)));
|
||||
for (const page of subtree)
|
||||
if (input.kind === "normal")
|
||||
remapIncludes(page.includes, new Map(), existing);
|
||||
const [rawOffers] = await tx.execute(
|
||||
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
|
||||
);
|
||||
const offers = rawOffers as unknown as DuplicateRow[];
|
||||
if (offers.length > 5000)
|
||||
throw new CatalogInputError("Duplicate at most 5000 offers at a time");
|
||||
const furniture = [
|
||||
...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))),
|
||||
].sort((a, b) => a - b);
|
||||
if (furniture.length) {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
|
||||
);
|
||||
const found = new Set(
|
||||
(rows as unknown as { id: number }[]).map((p) => Number(p.id)),
|
||||
);
|
||||
if (furniture.some((id) => !found.has(id)))
|
||||
throw new CatalogInputError("Furniture reference not found");
|
||||
}
|
||||
const destination = pages.find((p) => Number(p.id) === input.parentId);
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
input,
|
||||
subtree,
|
||||
offers,
|
||||
destination,
|
||||
siblings: pages.filter((page) =>
|
||||
input.parentId <= 0
|
||||
? Number(page.parent_id) <= 0
|
||||
: Number(page.parent_id) === input.parentId,
|
||||
),
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
return {
|
||||
pages,
|
||||
subtree,
|
||||
offers,
|
||||
fingerprint,
|
||||
destination: String(destination?.caption ?? "Root"),
|
||||
};
|
||||
}
|
||||
export async function previewDuplicateCommand(
|
||||
raw: DuplicateInput,
|
||||
): Promise<DuplicatePreview> {
|
||||
const input = schema.parse(raw);
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
const data = await snapshot(tx, input, false);
|
||||
return {
|
||||
...input,
|
||||
fingerprint: data.fingerprint,
|
||||
pages: data.subtree.length,
|
||||
offers: data.offers.length,
|
||||
destination: data.destination,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
export async function duplicateCategoryCommand(
|
||||
raw: DuplicatePreview,
|
||||
): Promise<{ id: number; pages: number; offers: number }> {
|
||||
const input = schema.parse(raw);
|
||||
const expected = z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.parse(raw.fingerprint);
|
||||
const count = z.number().int().min(0).max(5000).parse(raw.offers);
|
||||
// Reserve through the same allocator as regular imports before taking page locks.
|
||||
// A database uniqueness conflict rolls back every copied row, including pages.
|
||||
const reserved: number[] = [];
|
||||
if (input.kind === "normal")
|
||||
for (let i = 0; i < count; i++)
|
||||
reserved.push(await allocateCatalogItemId(async (id) => id));
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
const data = await snapshot(tx, input, true);
|
||||
if (data.fingerprint !== expected || data.offers.length !== count)
|
||||
throw new CatalogConflict(
|
||||
"Source or destination changed. Refresh the duplication preview.",
|
||||
);
|
||||
const ids = new Map<number, number>();
|
||||
const insert = async (row: Record<string, unknown>, offers = false) => {
|
||||
const entries = Object.entries(row).filter(([, v]) => v !== undefined);
|
||||
const [result] = await tx.execute(
|
||||
sql`INSERT INTO ${table(input.kind, offers)} SET ${sql.join(
|
||||
entries.map(([k, v]) => sql`${sql.identifier(k)}=${v}`),
|
||||
sql`, `,
|
||||
)}`,
|
||||
);
|
||||
const id = Number(
|
||||
row.id ?? (result as unknown as { insertId: number }).insertId,
|
||||
);
|
||||
if (!id) throw Error("Could not allocate duplicate category ID");
|
||||
return id;
|
||||
};
|
||||
for (const source of data.subtree) {
|
||||
const { id: oldId, ...row } = source;
|
||||
const isRoot = Number(oldId) === input.sourceId;
|
||||
row.parent_id = isRoot
|
||||
? input.parentId
|
||||
: ids.get(Number(source.parent_id));
|
||||
if (isRoot) {
|
||||
row.caption = input.name;
|
||||
row.enabled = "0";
|
||||
row.visible = "0";
|
||||
if (input.kind === "normal")
|
||||
row.caption_save = input.name.slice(0, 25);
|
||||
}
|
||||
if (input.kind === "normal") row.includes = "";
|
||||
ids.set(Number(oldId), await insert(row));
|
||||
}
|
||||
const existing = new Set(data.pages.map((p) => Number(p.id)));
|
||||
for (const source of data.subtree)
|
||||
if (input.kind === "normal" && source.includes) {
|
||||
const includes = remapIncludes(source.includes, ids, existing);
|
||||
if (includes.length > 128)
|
||||
throw new CatalogInputError(
|
||||
"Remapped category references exceed 128 characters",
|
||||
);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${table(input.kind)} SET includes=${includes} WHERE id=${ids.get(Number(source.id))}`,
|
||||
);
|
||||
}
|
||||
const offerIds = new Map(
|
||||
data.offers.map((offer, index) => [Number(offer.id), reserved[index]]),
|
||||
);
|
||||
for (const [index, source] of data.offers.entries()) {
|
||||
const { id: _id, ...row } = source;
|
||||
row.page_id = String(ids.get(Number(source.page_id)));
|
||||
if (input.kind === "normal") {
|
||||
row.id = reserved[index];
|
||||
if (Number(row.offer_id) > 0 && offerIds.has(Number(row.offer_id)))
|
||||
row.offer_id = offerIds.get(Number(row.offer_id));
|
||||
}
|
||||
await insert(row, true);
|
||||
}
|
||||
return {
|
||||
id: ids.get(input.sourceId) as number,
|
||||
pages: data.subtree.length,
|
||||
offers: data.offers.length,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function duplicateDestinationsCommand(
|
||||
kind: DuplicateInput["kind"],
|
||||
) {
|
||||
z.enum(["normal", "bc"]).parse(kind);
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id, caption FROM ${table(kind)} ORDER BY caption, id LIMIT 20001`,
|
||||
);
|
||||
const pages = rows as unknown as { id: number; caption: string }[];
|
||||
if (pages.length > 20000)
|
||||
throw new CatalogInputError(
|
||||
"Catalog exceeds duplication safety limit (20000 categories)",
|
||||
);
|
||||
return pages.map((row) => ({ id: Number(row.id), caption: row.caption }));
|
||||
}
|
||||
Reference in new issue
Block a user