Replace raw db.execute tuple casts with queryRows/rowsFrom/execResult/ affectedRows helpers from lib/db, drop redundant mysql2 casts on typed query builders, and centralize per-test fakeForm into test/fake-form. Update db mocks in tests so helpers resolve against mocked execute.
283 lines
7.5 KiB
TypeScript
283 lines
7.5 KiB
TypeScript
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,
|
|
audit: [] as Array<{ before: string; after: string; target: string }>,
|
|
failAt: 0,
|
|
commits: 0,
|
|
rollbacks: 0,
|
|
}));
|
|
vi.mock("@/lib/db", async () => {
|
|
const 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 { 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,
|
|
[],
|
|
];
|
|
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;
|
|
}
|
|
},
|
|
};
|
|
return {
|
|
db,
|
|
...(await import("@/test/db-helpers")).createDbHelpers(db.execute),
|
|
};
|
|
});
|
|
|
|
import { db } from "@/lib/db";
|
|
|
|
vi.mock("@/features/operations/server", () => {
|
|
return {
|
|
runOperation: (
|
|
_input: unknown,
|
|
work: (tx: unknown, id: string) => Promise<unknown>,
|
|
) => db.transaction((tx) => work(tx, "operation-fixture")),
|
|
enqueueEffect: vi.fn(),
|
|
};
|
|
});
|
|
|
|
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.audit = [];
|
|
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);
|
|
});
|
|
|
|
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,
|
|
});
|
|
});
|