feat(catalog): add reviewed bulk edits and complete category duplication
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m11s

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,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);
});