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