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,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);
|
||||
});
|
||||
Reference in new issue
Block a user