import { describe, it, expect, beforeAll, afterAll } from "vitest"; import prisma from "../config/database"; import { confirmInventorySession } from "../services/warehouse.service"; /** * Pinning tests for audit finding C3: confirmInventorySession must be atomic. * A session whose item list contains a valid surplus line BEFORE a deficit * line that cannot be satisfied (no batches) must fail WITHOUT committing the * surplus item's corrective receipt/batch — and retries must not accumulate * phantom stock. */ const N = "wh_invconf_"; let itemAId: number; // surplus line (processed first — lower line id) let itemBId: number; // deficit line with no batches -> selectFifoBatches throws let sessionId: number; /** Corrective receipts carry generated notes without our prefix — collect * them via their lines' items before deleting. */ async function cleanup() { const lines = await prisma.sklad_receipt_lines.findMany({ where: { item: { name: { contains: N } } }, select: { receipt_id: true }, }); const receiptIds = [...new Set(lines.map((l) => l.receipt_id))]; await prisma.sklad_batches.deleteMany({ where: { item: { name: { contains: N } } }, }); await prisma.sklad_receipt_lines.deleteMany({ where: { item: { name: { contains: N } } }, }); if (receiptIds.length > 0) { await prisma.sklad_receipts.deleteMany({ where: { id: { in: receiptIds } }, }); } await prisma.sklad_inventory_lines.deleteMany({ where: { session: { notes: { contains: N } } }, }); await prisma.sklad_inventory_sessions.deleteMany({ where: { notes: { contains: N } }, }); await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } }); } beforeAll(async () => { await cleanup(); const itemA = await prisma.sklad_items.create({ data: { name: `${N}surplus_item`, unit: "ks" }, }); itemAId = itemA.id; const itemB = await prisma.sklad_items.create({ data: { name: `${N}deficit_item`, unit: "ks" }, }); itemBId = itemB.id; // Surplus line created first => lower id => processed first by the confirm // loop. Deficit line for an item with NO batches forces the FIFO throw. const session = await prisma.sklad_inventory_sessions.create({ data: { notes: `${N}session`, items: { create: [ { item_id: itemAId, system_qty: 0, actual_qty: 5, difference: 5 }, { item_id: itemBId, system_qty: 3, actual_qty: 0, difference: -3 }, ], }, }, }); sessionId = session.id; }); afterAll(async () => { await cleanup(); await prisma.$disconnect(); }); describe("confirmInventorySession atomicity (audit C3)", () => { it("rolls back the surplus corrective receipt when a later deficit line fails", async () => { const result = await confirmInventorySession(sessionId); // The confirm must fail — item B has nothing to consume. expect("error" in result).toBe(true); if ("error" in result) { expect(result.status).toBe(400); expect(result.error).toContain(`ID ${itemBId}`); } // ...and item A's surplus writes must NOT have been committed. const receiptLines = await prisma.sklad_receipt_lines.findMany({ where: { item_id: itemAId }, }); expect(receiptLines).toHaveLength(0); const batches = await prisma.sklad_batches.findMany({ where: { item_id: itemAId }, }); expect(batches).toHaveLength(0); // The session itself stays DRAFT and unnumbered either way. const session = await prisma.sklad_inventory_sessions.findUnique({ where: { id: sessionId }, }); expect(session?.status).toBe("DRAFT"); expect(session?.session_number).toBeNull(); }); it("does not accumulate phantom stock when the failed confirm is retried", async () => { await confirmInventorySession(sessionId); await confirmInventorySession(sessionId); const batches = await prisma.sklad_batches.findMany({ where: { item_id: itemAId }, }); expect(batches).toHaveLength(0); const receipts = await prisma.sklad_receipts.findMany({ where: { notes: { contains: `inventarizace #${sessionId}` } }, }); expect(receipts).toHaveLength(0); }); });