import { describe, it, expect, beforeAll, afterAll } from "vitest"; import prisma from "../config/database"; import { confirmIssue } from "../services/warehouse.service"; /** * Pinning tests for audit finding C2: confirmIssue must validate duplicate * lines pointing at the SAME batch against their combined quantity. Two lines * that each pass the per-line check individually must not decrement the batch * cumulatively below zero (silent over-issue hidden from stock totals). * * The duplicate-lines draft is exactly what POST /issues produces when two * same-item lines without batch_id FIFO-resolve to the same oldest batch * (per-line resolution persists nothing between lines), and is equally * reachable with explicit batch_ids. */ const N = "wh_issueconf_"; let projectId: number; let userId: number; let itemId: number; let batch1Id: number; let overIssueId: number; let exactIssueId: number; async function cleanup() { await prisma.sklad_issue_lines.deleteMany({ where: { issue: { notes: { contains: N } } }, }); await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } }); await prisma.sklad_batches.deleteMany({ where: { item: { name: { contains: N } } }, }); await prisma.sklad_receipt_lines.deleteMany({ where: { item: { name: { contains: N } } }, }); await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } }); await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } }); await prisma.users .deleteMany({ where: { username: { startsWith: N } } }) .catch(() => {}); await prisma.projects .deleteMany({ where: { name: { startsWith: N } } }) .catch(() => {}); } beforeAll(async () => { await cleanup(); const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } }); if (!adminRole) throw new Error("Admin role not found — seed the database first"); const user = await prisma.users.create({ data: { username: `${N}user`, email: `${N}user@test.local`, password_hash: "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", first_name: "Issue", last_name: "Confirm", is_active: true, role_id: adminRole.id, }, }); userId = user.id; const project = await prisma.projects.create({ data: { name: `${N}project`, status: "active" }, }); projectId = project.id; const item = await prisma.sklad_items.create({ data: { name: `${N}item`, unit: "ks" }, }); itemId = item.id; // Two batches of 8 — total stock 16, so the per-item aggregate check // passes a combined quantity of 10, but FIFO resolves both lines to the // OLDER batch B1 which only holds 8. const receipt = await prisma.sklad_receipts.create({ data: { notes: `${N}receipt`, status: "CONFIRMED" }, }); const line1 = await prisma.sklad_receipt_lines.create({ data: { receipt_id: receipt.id, item_id: itemId, quantity: 8, unit_price: 10, }, }); const line2 = await prisma.sklad_receipt_lines.create({ data: { receipt_id: receipt.id, item_id: itemId, quantity: 8, unit_price: 10, }, }); const batch1 = await prisma.sklad_batches.create({ data: { item_id: itemId, receipt_line_id: line1.id, quantity: 8, original_qty: 8, unit_price: 10, received_at: new Date(2026, 0, 1, 12, 0, 0), is_consumed: false, }, }); batch1Id = batch1.id; await prisma.sklad_batches.create({ data: { item_id: itemId, receipt_line_id: line2.id, quantity: 8, original_qty: 8, unit_price: 10, received_at: new Date(2026, 0, 2, 12, 0, 0), is_consumed: false, }, }); // Draft mirroring the FIFO resolution: two lines, both on B1, 5 + 5 = 10 > 8. const overIssue = await prisma.sklad_issues.create({ data: { project_id: projectId, notes: `${N}over_issue`, items: { create: [ { item_id: itemId, batch_id: batch1Id, quantity: 5 }, { item_id: itemId, batch_id: batch1Id, quantity: 5 }, ], }, }, }); overIssueId = overIssue.id; // Control draft: duplicate lines whose combined quantity EXACTLY fits B1. const exactIssue = await prisma.sklad_issues.create({ data: { project_id: projectId, notes: `${N}exact_issue`, items: { create: [ { item_id: itemId, batch_id: batch1Id, quantity: 3 }, { item_id: itemId, batch_id: batch1Id, quantity: 5 }, ], }, }, }); exactIssueId = exactIssue.id; }); afterAll(async () => { await cleanup(); await prisma.$disconnect(); }); describe("confirmIssue cross-line batch totals (audit C2)", () => { it("rejects duplicate same-batch lines whose combined quantity exceeds the batch", async () => { const result = await confirmIssue(overIssueId, userId); expect("error" in result).toBe(true); if ("error" in result) expect(result.status).toBe(400); // The batch must be untouched — not driven negative and hidden. const b1 = await prisma.sklad_batches.findUnique({ where: { id: batch1Id }, }); expect(Number(b1?.quantity)).toBe(8); expect(b1?.is_consumed).toBe(false); const negatives = await prisma.sklad_batches.findMany({ where: { item_id: itemId, quantity: { lt: 0 } }, }); expect(negatives).toHaveLength(0); const issue = await prisma.sklad_issues.findUnique({ where: { id: overIssueId }, }); expect(issue?.status).toBe("DRAFT"); }); it("still confirms duplicate same-batch lines whose combined quantity fits", async () => { const result = await confirmIssue(exactIssueId, userId); expect("error" in result).toBe(false); const b1 = await prisma.sklad_batches.findUnique({ where: { id: batch1Id }, }); expect(Number(b1?.quantity)).toBe(0); expect(b1?.is_consumed).toBe(true); }); });