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) => { 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); });