feat(catalog): undo bulk offer edits with guarded history restoration
CI / check (push) Successful in 2m1s
CI / deploy (push) Successful in 18s
CI / publish-container (push) Successful in 1m28s

This commit is contained in:
Simo committed 2026-09-11 10:47:46 +02:00
1 parent 13bd3695cb
commit c5a2b44807
33 files changed
+1399 -116

No files matched your search

@@ -27,6 +27,7 @@ const state = vi.hoisted(() => ({
],
queries: [] as string[],
writes: 0,
audit: [] as Array<{ before: string; after: string; target: string }>,
failAt: 0,
commits: 0,
rollbacks: 0,
@@ -36,11 +37,35 @@ vi.mock("@/lib/db", () => ({
execute: async () => [state.pages, []],
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = state.writes;
const beforeRows = structuredClone(state.rows);
const beforeAudit = [...state.audit];
try {
const result = await fn({
execute: async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
const { sql: text, params } = new MySqlDialect().sqlToQuery(query);
state.queries.push(text);
if (
text.startsWith("SELECT") &&
!text.includes("catalog_name") &&
text.includes("catalog_items")
) {
const row = state.rows.find(
(row) => row.id === Number(params.at(-1)),
);
return [
row
? [
{
pageId: String(row.pageId),
costCredits: row.costCredits,
costPoints: row.costPoints,
pointsType: row.pointsType,
},
]
: [],
[],
];
}
if (text.startsWith("SELECT"))
return [
text.includes("catalog_pages") ? state.pages : state.rows,
@@ -48,13 +73,41 @@ vi.mock("@/lib/db", () => ({
];
state.writes++;
if (state.writes === state.failAt) throw Error("write failed");
const row = state.rows.find(
(row) => row.id === Number(params.at(-1)),
);
if (row) {
const columns = {
page_id: "pageId",
cost_credits: "costCredits",
cost_points: "costPoints",
points_type: "pointsType",
} as const;
const assignments =
text.split(" SET ")[1]?.split(" WHERE ")[0] ?? "";
let index = 0;
for (const assignment of assignments.split(", ")) {
const name = assignment.match(
/`([^`]+)`/,
)?.[1] as keyof typeof columns;
if (columns[name]) row[columns[name]] = Number(params[index++]);
}
}
return [{ affectedRows: 1 }, []];
},
insert: () => ({
values: async (entry: (typeof state.audit)[number]) => {
state.audit.push(entry);
return [{ insertId: 1000 + state.audit.length }];
},
}),
});
state.commits++;
return result;
} catch (error) {
state.writes = before;
state.rows = beforeRows;
state.audit = beforeAudit;
state.rollbacks++;
throw error;
}
@@ -96,6 +149,7 @@ beforeEach(() => {
{ id: 9, caption: "Target" },
];
state.queries = [];
state.audit = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
@@ -192,3 +246,21 @@ it("does not update offers whose chosen values already match", async () => {
await applyBulkOffersCommand(request, p.fingerprint);
expect(state.writes).toBe(0);
});
it("records category-only bulk edits as restorable history and returns durable IDs", async () => {
const request = { ids: [1, 2], changes: { pageId: 9 } };
const preview = await previewBulkOffersCommand(request);
expect(await applyBulkOffersCommand(request, preview.fingerprint, 7)).toEqual(
{ changedCount: 2, historyIds: [1001, 1002] },
);
expect(state.audit).toHaveLength(2);
expect(state.audit[0].target).toBe("catalog_offer");
expect(JSON.parse(state.audit[0].before)).toMatchObject({
pageId: "4",
costCredits: 3,
});
expect(JSON.parse(state.audit[0].after)).toMatchObject({
pageId: "9",
costCredits: 3,
});
});