Migration 20260612234500: CHECK (quantity >= 0) on sklad_batches and sklad_item_locations — the DB itself now rejects negative stock, so a future C2-class bug (cumulative decrements driving a batch negative and silently vanishing from totals) becomes a loud error instead of hidden corruption. All three databases verified clean of negative rows before the migration. audit_logs.created_at TIMESTAMP(0) -> DATETIME(0): TIMESTAMP dies in 2038 and converts through the session timezone; existing wall times preserved by the MODIFY. Pinned by db-constraints.test.ts (4 tests, each watched fail pre-migration: negative insert/update/location all succeeded before, 2098 audit timestamp was rejected before). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
3.8 KiB
TypeScript
137 lines
3.8 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import prisma from "../config/database";
|
|
|
|
/**
|
|
* Pins the 2026-06-12 DB hardening migration:
|
|
* 1. CHECK (quantity >= 0) on sklad_batches + sklad_item_locations — the DB
|
|
* itself rejects negative stock, so a future C2-class bug (cumulative
|
|
* decrements driving a batch negative) becomes a loud error instead of
|
|
* silently hidden corruption.
|
|
* 2. audit_logs.created_at is DATETIME, not TIMESTAMP — no 2038 cap.
|
|
*/
|
|
|
|
const N = "db_constraints_";
|
|
|
|
let itemId: number;
|
|
let locationId: number;
|
|
let receiptId: number;
|
|
let lineAId: number;
|
|
let lineBId: number;
|
|
|
|
async function cleanup() {
|
|
await prisma.sklad_batches.deleteMany({
|
|
where: { item: { name: { contains: N } } },
|
|
});
|
|
await prisma.sklad_item_locations.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.sklad_locations.deleteMany({
|
|
where: { name: { contains: N } },
|
|
});
|
|
await prisma.audit_logs.deleteMany({
|
|
where: { description: { contains: N } },
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await cleanup();
|
|
const item = await prisma.sklad_items.create({
|
|
data: { name: `${N}item`, unit: "ks" },
|
|
});
|
|
itemId = item.id;
|
|
const location = await prisma.sklad_locations.create({
|
|
data: { code: `DBC${Date.now() % 100000}`, name: `${N}loc` },
|
|
});
|
|
locationId = location.id;
|
|
const receipt = await prisma.sklad_receipts.create({
|
|
data: { notes: `${N}receipt`, status: "CONFIRMED" },
|
|
});
|
|
receiptId = receipt.id;
|
|
const lineA = await prisma.sklad_receipt_lines.create({
|
|
data: {
|
|
receipt_id: receiptId,
|
|
item_id: itemId,
|
|
quantity: 5,
|
|
unit_price: 1,
|
|
},
|
|
});
|
|
lineAId = lineA.id;
|
|
const lineB = await prisma.sklad_receipt_lines.create({
|
|
data: {
|
|
receipt_id: receiptId,
|
|
item_id: itemId,
|
|
quantity: 5,
|
|
unit_price: 1,
|
|
},
|
|
});
|
|
lineBId = lineB.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanup();
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("stock CHECK constraints", () => {
|
|
it("rejects inserting a negative batch quantity", async () => {
|
|
await expect(
|
|
prisma.sklad_batches.create({
|
|
data: {
|
|
item_id: itemId,
|
|
receipt_line_id: lineAId,
|
|
quantity: -1,
|
|
original_qty: 5,
|
|
unit_price: 1,
|
|
is_consumed: false,
|
|
},
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects updating a batch quantity below zero", async () => {
|
|
const batch = await prisma.sklad_batches.create({
|
|
data: {
|
|
item_id: itemId,
|
|
receipt_line_id: lineBId,
|
|
quantity: 5,
|
|
original_qty: 5,
|
|
unit_price: 1,
|
|
is_consumed: false,
|
|
},
|
|
});
|
|
await expect(
|
|
prisma.$executeRaw`UPDATE sklad_batches SET quantity = -2 WHERE id = ${batch.id}`,
|
|
).rejects.toThrow();
|
|
const fresh = await prisma.sklad_batches.findUnique({
|
|
where: { id: batch.id },
|
|
});
|
|
expect(Number(fresh?.quantity)).toBe(5);
|
|
});
|
|
|
|
it("rejects a negative item_location quantity", async () => {
|
|
await expect(
|
|
prisma.sklad_item_locations.create({
|
|
data: { item_id: itemId, location_id: locationId, quantity: -1 },
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("audit_logs.created_at has no 2038 cap", () => {
|
|
it("accepts a far-future timestamp (DATETIME, not TIMESTAMP)", async () => {
|
|
const row = await prisma.audit_logs.create({
|
|
data: {
|
|
action: "test",
|
|
description: `${N}future`,
|
|
created_at: new Date(Date.UTC(2098, 0, 15, 12, 0, 0)),
|
|
},
|
|
});
|
|
expect(row.created_at?.getUTCFullYear()).toBe(2098);
|
|
});
|
|
});
|