feat(catalog): undo bulk offer edits with guarded history restoration
This commit is contained in:
1 parent
13bd3695cb
commit
c5a2b44807
33 files changed
+1399
-116
No files matched your search
@@ -5,6 +5,7 @@ const state = vi.hoisted(() => ({
|
||||
preview: vi.fn(),
|
||||
destinations: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
export: vi.fn(),
|
||||
send: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
@@ -18,6 +19,7 @@ vi.mock("@/features/catalog/server/bulk-offers", () => ({
|
||||
previewBulkOffersCommand: state.preview,
|
||||
listBulkOfferDestinationsCommand: state.destinations,
|
||||
applyBulkOffersCommand: state.apply,
|
||||
undoBulkOffersCommand: state.undo,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: state.export,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
applyBulkOffers,
|
||||
getBulkOfferDestinations,
|
||||
previewBulkOffers,
|
||||
undoBulkOffers,
|
||||
} from "@/actions/catalog-bulk";
|
||||
|
||||
const input = { ids: [1], changes: { pageId: 2 } };
|
||||
@@ -87,3 +90,20 @@ it("loads destination choices with view permission only", async () => {
|
||||
expect(state.permission).toHaveBeenCalledWith("view");
|
||||
expect(state.export).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("undo enforces edit access and exports only successful restores", async () => {
|
||||
state.undo.mockResolvedValueOnce({ changedCount: 2 });
|
||||
expect((await undoBulkOffers([10, 11])).ok).toBe(true);
|
||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
||||
expect(state.undo).toHaveBeenCalledWith([10, 11], 1);
|
||||
expect(state.export).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("undo denial stops before export and a conflict does not notify", async () => {
|
||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
||||
await expect(undoBulkOffers([10])).rejects.toThrow("Denied");
|
||||
expect(state.export).not.toHaveBeenCalled();
|
||||
state.undo.mockRejectedValueOnce(Error("conflict"));
|
||||
expect((await undoBulkOffers([10])).ok).toBe(false);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { recordHistory } from "@/features/history/server";
|
||||
import {
|
||||
applyHistory,
|
||||
lockOfferHistoryPages,
|
||||
readHistory,
|
||||
recordHistory,
|
||||
} from "@/features/history/server";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type BulkOfferInput,
|
||||
@@ -132,6 +137,7 @@ export async function applyBulkOffersCommand(
|
||||
costPoints: "cost_points",
|
||||
pointsType: "points_type",
|
||||
};
|
||||
const historyIds: number[] = [];
|
||||
for (const row of result.rows) {
|
||||
const changes = (
|
||||
Object.keys(input.changes) as Array<keyof BulkOfferValues>
|
||||
@@ -146,14 +152,22 @@ export async function applyBulkOffersCommand(
|
||||
sql`, `,
|
||||
)} WHERE id=${row.id}`,
|
||||
);
|
||||
if (userId)
|
||||
await recordHistory(tx, "prices", row.id, userId, {
|
||||
costCredits: row.before.costCredits,
|
||||
costPoints: row.before.costPoints,
|
||||
pointsType: row.before.pointsType,
|
||||
});
|
||||
if (userId) {
|
||||
const historyId = await recordHistory(
|
||||
tx,
|
||||
"catalog_offer",
|
||||
row.id,
|
||||
userId,
|
||||
{
|
||||
...row.before,
|
||||
pageId: String(row.before.pageId),
|
||||
},
|
||||
);
|
||||
if (!historyId) throw Error("History entry could not be recorded");
|
||||
historyIds.push(historyId);
|
||||
}
|
||||
}
|
||||
return { changedCount: result.changedCount };
|
||||
return { changedCount: result.changedCount, historyIds };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,3 +184,40 @@ export async function listBulkOfferDestinationsCommand() {
|
||||
);
|
||||
return { pages };
|
||||
}
|
||||
|
||||
export async function undoBulkOffersCommand(
|
||||
historyIds: number[],
|
||||
userId: number,
|
||||
) {
|
||||
if (
|
||||
!Array.isArray(historyIds) ||
|
||||
!historyIds.length ||
|
||||
historyIds.length > 500 ||
|
||||
new Set(historyIds).size !== historyIds.length ||
|
||||
historyIds.some((id) => !Number.isSafeInteger(id) || id < 1)
|
||||
)
|
||||
throw new CatalogInputError("Invalid undo selection");
|
||||
return db.transaction(async (tx) => {
|
||||
const entries = [];
|
||||
for (const id of historyIds) {
|
||||
const entry = await readHistory(tx, id);
|
||||
if (entry.kind !== "catalog_offer")
|
||||
throw new CatalogInputError("Invalid undo selection");
|
||||
entries.push(entry);
|
||||
}
|
||||
entries.sort((a, b) => Number(a.targetId) - Number(b.targetId));
|
||||
if (new Set(entries.map((entry) => entry.targetId)).size !== entries.length)
|
||||
throw new CatalogInputError("Duplicate undo target");
|
||||
await lockOfferHistoryPages(tx, entries);
|
||||
try {
|
||||
for (const entry of entries) await applyHistory(tx, entry, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "conflict")
|
||||
throw new CatalogConflict(
|
||||
"Offers changed after this update. Undo was not applied.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return { changedCount: entries.length };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
current: [
|
||||
{ pageId: "9", costCredits: 20, costPoints: 0, pointsType: 0 },
|
||||
{ pageId: "9", costCredits: 30, costPoints: 0, pointsType: 0 },
|
||||
],
|
||||
audit: [] as unknown[],
|
||||
queries: [] as string[],
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const saved = structuredClone(state.current);
|
||||
const savedAudit = [...state.audit];
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: (query: SQL) => ({
|
||||
limit: async () => {
|
||||
const id = Number(
|
||||
new MySqlDialect().sqlToQuery(query).params[0],
|
||||
);
|
||||
return [
|
||||
{
|
||||
id,
|
||||
action: "history_update",
|
||||
target: "catalog_offer",
|
||||
targetId: id,
|
||||
before: JSON.stringify({
|
||||
pageId: "4",
|
||||
costCredits: id * 10,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
}),
|
||||
after: JSON.stringify({
|
||||
pageId: "9",
|
||||
costCredits: (id + 1) * 10,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
execute: async (query: SQL) => {
|
||||
const { sql: text, params } = new MySqlDialect().sqlToQuery(query);
|
||||
state.queries.push(text);
|
||||
if (text.includes("catalog_pages")) return [[{ id: 4 }, { id: 9 }]];
|
||||
const id = Number(params.at(-1));
|
||||
if (text.startsWith("SELECT")) return [[state.current[id - 1]]];
|
||||
state.current[id - 1] = {
|
||||
pageId: String(params[0]),
|
||||
costCredits: Number(params[1]),
|
||||
costPoints: Number(params[2]),
|
||||
pointsType: Number(params[3]),
|
||||
};
|
||||
return [{}];
|
||||
},
|
||||
insert: () => ({
|
||||
values: async (entry: unknown) => {
|
||||
state.audit.push(entry);
|
||||
return [{ insertId: 100 + state.audit.length }];
|
||||
},
|
||||
}),
|
||||
};
|
||||
try {
|
||||
return await fn(tx);
|
||||
} catch (error) {
|
||||
state.current = saved;
|
||||
state.audit = savedAudit;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { undoBulkOffersCommand } from "./bulk-offers";
|
||||
|
||||
beforeEach(() => {
|
||||
state.current = [
|
||||
{ pageId: "9", costCredits: 20, costPoints: 0, pointsType: 0 },
|
||||
{ pageId: "9", costCredits: 30, costPoints: 0, pointsType: 0 },
|
||||
];
|
||||
state.audit = [];
|
||||
state.queries = [];
|
||||
});
|
||||
it("restores the whole batch and records each restore in history", async () => {
|
||||
expect(await undoBulkOffersCommand([2, 1], 7)).toEqual({ changedCount: 2 });
|
||||
expect(state.current.map((row) => row.pageId)).toEqual(["4", "4"]);
|
||||
expect(state.current.map((row) => row.costCredits)).toEqual([10, 20]);
|
||||
expect(state.audit).toHaveLength(2);
|
||||
expect(state.queries[0]).toContain("catalog_pages");
|
||||
});
|
||||
it("rolls back earlier restores and history when a later offer changed", async () => {
|
||||
state.current[1].costPoints = 99;
|
||||
await expect(undoBulkOffersCommand([1, 2], 7)).rejects.toThrow(/changed/);
|
||||
expect(state.current[0].costCredits).toBe(20);
|
||||
expect(state.current[1].costPoints).toBe(99);
|
||||
expect(state.audit).toHaveLength(0);
|
||||
});
|
||||
it("rejects duplicate history IDs and repeated undo", async () => {
|
||||
await expect(undoBulkOffersCommand([1, 1], 7)).rejects.toThrow(/Invalid/);
|
||||
await undoBulkOffersCommand([1, 2], 7);
|
||||
await expect(undoBulkOffersCommand([1, 2], 7)).rejects.toThrow(/changed/);
|
||||
expect(state.audit).toHaveLength(2);
|
||||
});
|
||||
Reference in new issue
Block a user