Files
app/src/__tests__/warehouse.test.ts
BOHA 1826fc7976 fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md

- attendance: schemas accept the combined local datetimes the forms/service
  use (new dateTimeString helpers in schemas/common.ts), breaks persist on
  create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
  enrollment QR generated locally via qrcode (CSP-blocked external service
  also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
  coercion in formatters.ts), billing_text persists on update, issued-order
  status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
  (shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
  PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
  escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
  downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
  longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
  works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
  @hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
  the Prisma 6 downgrade), drop deprecated @types stubs

Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:59:47 +02:00

1547 lines
48 KiB
TypeScript

import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
vi,
} from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import warehouseRoutes from "../routes/admin/warehouse";
import { nasInvoicesManager } from "../services/nas-financials-manager";
import { securityHeaders } from "../middleware/security";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Short unique prefix for test data (location codes are VarChar(20)) */
const LOC = "WT"; // prefix for location codes
let counter = 0;
function uniqueCode() {
return `${LOC}${++counter}`; // e.g. WT1, WT2, ...
}
/** Name prefix for items/categories/suppliers (longer names are fine) */
const N = "wh_test_";
/** Build a Fastify app with warehouse routes and required plugins. */
async function buildApp() {
const app = Fastify({ logger: false });
await app.register(cookie);
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
// multipart is registered inside warehouseRoutes itself, do not register here
app.addHook("onRequest", securityHeaders);
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
return app;
}
/** Generate a valid JWT access token for a given user. */
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
/** Create auth header from token. */
function authHeader(token: string) {
return { Authorization: `Bearer ${token}` };
}
// ---------------------------------------------------------------------------
// Test fixtures: user, role, project
// ---------------------------------------------------------------------------
let app: Awaited<ReturnType<typeof buildApp>>;
let adminToken: string;
let noPermToken: string;
let adminUserId: number;
let noPermUserId: number;
let noPermRoleId: number;
let projectId: number;
beforeAll(async () => {
app = await buildApp();
// Clean up any leftover test data from a previous failed run
await prisma.sklad_issue_lines.deleteMany({
where: { issue: { notes: { contains: N } } },
});
await prisma.sklad_issues.deleteMany({
where: { notes: { contains: N } },
});
await prisma.sklad_reservations.deleteMany({
where: { notes: { contains: N } },
});
await prisma.sklad_batches.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipt_lines.deleteMany({
where: { receipt: { notes: { contains: N } } },
});
await prisma.sklad_receipts.deleteMany({
where: { notes: { contains: N } },
});
await prisma.sklad_item_locations.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_items.deleteMany({
where: { name: { contains: N } },
});
await prisma.sklad_suppliers.deleteMany({
where: { name: { contains: N } },
});
await prisma.sklad_locations.deleteMany({
where: { code: { startsWith: LOC } },
});
await prisma.sklad_categories.deleteMany({
where: { name: { contains: N } },
});
await prisma.sklad_inventory_lines.deleteMany({
where: { session: { notes: { contains: N } } },
});
await prisma.sklad_inventory_sessions.deleteMany({
where: { notes: { contains: N } },
});
// Delete leftover test users and roles
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await prisma.roles
.deleteMany({ where: { name: { startsWith: N } } })
.catch(() => {});
await prisma.projects
.deleteMany({ where: { name: { startsWith: N } } })
.catch(() => {});
// Create a test admin user (bypasses all permission checks)
const adminRole = await prisma.roles.findFirst({
where: { name: "admin" },
});
if (!adminRole)
throw new Error("Admin role not found — seed the database first");
const adminUser = await prisma.users.create({
data: {
username: `${N}admin`,
email: `${N}admin@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Test",
last_name: "Admin",
is_active: true,
role_id: adminRole.id,
},
});
adminUserId = adminUser.id;
adminToken = generateToken({
id: adminUser.id,
username: adminUser.username,
roleName: "admin",
});
// Create a role with NO warehouse permissions for permission check tests
const noPermRole = await prisma.roles.create({
data: {
name: `${N}no_warehouse`,
display_name: "Test No Warehouse",
description: "Test role without warehouse permissions",
},
});
noPermRoleId = noPermRole.id;
const noPermUser = await prisma.users.create({
data: {
username: `${N}noperm`,
email: `${N}noperm@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "No",
last_name: "Perm",
is_active: true,
role_id: noPermRole.id,
},
});
noPermUserId = noPermUser.id;
noPermToken = generateToken({
id: noPermUser.id,
username: noPermUser.username,
roleName: noPermRole.name,
});
// Create a test project (needed for issues and reservations)
const project = await prisma.projects.create({
data: {
name: `${N}project`,
status: "active",
},
});
projectId = project.id;
});
afterAll(async () => {
// Clean up all test data in reverse dependency order
await prisma.sklad_issue_lines.deleteMany({
where: { issue: { notes: { contains: N } } },
});
await prisma.sklad_issues.deleteMany({
where: { notes: { contains: N } },
});
await prisma.sklad_reservations.deleteMany({
where: { notes: { contains: N } },
});
// Batches reference receipt_lines, so delete batches first
await prisma.sklad_batches.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipt_lines.deleteMany({
where: { receipt: { notes: { contains: N } } },
});
await prisma.sklad_receipts.deleteMany({
where: { notes: { contains: N } },
});
await prisma.sklad_item_locations.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_items.deleteMany({
where: { name: { contains: N } },
});
await prisma.sklad_suppliers.deleteMany({
where: { name: { contains: N } },
});
await prisma.sklad_locations.deleteMany({
where: { code: { startsWith: LOC } },
});
await prisma.sklad_categories.deleteMany({
where: { name: { contains: N } },
});
await prisma.sklad_inventory_lines.deleteMany({
where: { session: { notes: { contains: N } } },
});
await prisma.sklad_inventory_sessions.deleteMany({
where: { notes: { contains: N } },
});
// Clean up test user and role (guard against beforeAll failure)
if (noPermUserId)
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
if (noPermRoleId)
await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {});
if (adminUserId)
await prisma.users.delete({ where: { id: adminUserId } }).catch(() => {});
// Clean up test project
if (projectId)
await prisma.projects.delete({ where: { id: projectId } }).catch(() => {});
if (app) await app.close();
});
// ---------------------------------------------------------------------------
// 1. Category CRUD
// ---------------------------------------------------------------------------
describe("Category CRUD", () => {
let createdCategoryId: number;
it("creates a category", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/warehouse/categories",
headers: authHeader(adminToken),
payload: { name: `${N}cat1`, description: "Test category" },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}cat1`);
createdCategoryId = body.data.id;
});
it("lists categories", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/categories",
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
const found = body.data.find(
(c: { id: number }) => c.id === createdCategoryId,
);
expect(found).toBeDefined();
});
it("updates a category", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/admin/warehouse/categories/${createdCategoryId}`,
headers: authHeader(adminToken),
payload: { name: `${N}cat1_updated` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}cat1_updated`);
});
it("deletes a category with no items", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/categories/${createdCategoryId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
});
it("blocks delete if items reference the category", async () => {
// Create a category and an item referencing it
const catRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/categories",
headers: authHeader(adminToken),
payload: { name: `${N}cat_blocked` },
});
const catId = catRes.json().data.id;
await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: {
name: `${N}item_cat_block`,
unit: "ks",
category_id: catId,
},
});
const delRes = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/categories/${catId}`,
headers: authHeader(adminToken),
});
expect(delRes.statusCode).toBe(409);
const body = delRes.json();
expect(body.success).toBe(false);
});
it("returns 404 for deleting nonexistent category", async () => {
const res = await app.inject({
method: "DELETE",
url: "/api/admin/warehouse/categories/999999",
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// 2. Supplier CRUD
// ---------------------------------------------------------------------------
describe("Supplier CRUD", () => {
let createdSupplierId: number;
it("creates a supplier", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/warehouse/suppliers",
headers: authHeader(adminToken),
payload: { name: `${N}supplier1`, ico: "12345678" },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}supplier1`);
expect(body.data.is_active).toBe(true);
createdSupplierId = body.data.id;
});
it("lists suppliers", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/suppliers",
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
});
it("gets supplier detail", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/suppliers/${createdSupplierId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}supplier1`);
});
it("updates a supplier", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/admin/warehouse/suppliers/${createdSupplierId}`,
headers: authHeader(adminToken),
payload: { name: `${N}supplier1_updated` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}supplier1_updated`);
});
it("soft-deletes a supplier (sets is_active=false)", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/suppliers/${createdSupplierId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
// Verify is_active is now false
const checkRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/suppliers/${createdSupplierId}`,
headers: authHeader(adminToken),
});
expect(checkRes.json().data.is_active).toBe(false);
});
it("returns 404 for nonexistent supplier", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/suppliers/999999",
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
});
});
// ---------------------------------------------------------------------------
// 3. Location CRUD
// ---------------------------------------------------------------------------
describe("Location CRUD", () => {
let createdLocationId: number;
let createdLocationCode: string;
it("creates a location", async () => {
createdLocationCode = uniqueCode();
const res = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: createdLocationCode, name: `${N}location1` },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.code).toBe(createdLocationCode);
createdLocationId = body.data.id;
});
it("rejects duplicate location code", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: createdLocationCode, name: "duplicate" },
});
expect(res.statusCode).toBe(409);
});
it("lists locations (only active)", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
const found = body.data.find(
(l: { id: number }) => l.id === createdLocationId,
);
expect(found).toBeDefined();
});
it("updates a location", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/admin/warehouse/locations/${createdLocationId}`,
headers: authHeader(adminToken),
payload: { name: `${N}location1_updated` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}location1_updated`);
});
it("deletes a location with no items at it", async () => {
// First create a fresh location with no items
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "to delete" },
});
const locId = createRes.json().data.id;
const delRes = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/locations/${locId}`,
headers: authHeader(adminToken),
});
expect(delRes.statusCode).toBe(200);
});
it("blocks delete if items with non-zero quantity are at the location", async () => {
// Create a location, item, and a confirmed receipt to put stock at the location
const locRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "blocked loc" },
});
const blockedLocId = locRes.json().data.id;
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_loc_block`, unit: "ks" },
});
const blockItemId = itemRes.json().data.id;
// Create and confirm a receipt to put stock at the location
const receiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}receipt_for_loc_block`,
items: [
{
item_id: blockItemId,
quantity: 10,
unit_price: 100,
location_id: blockedLocId,
},
],
},
});
const receiptId = receiptRes.json().data.id;
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptId}/confirm`,
headers: authHeader(adminToken),
});
// Now try to delete the location — should be blocked
const delRes = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/locations/${blockedLocId}`,
headers: authHeader(adminToken),
});
expect(delRes.statusCode).toBe(409);
});
});
// ---------------------------------------------------------------------------
// 4. Item CRUD
// ---------------------------------------------------------------------------
describe("Item CRUD", () => {
let createdItemId: number;
it("creates an item", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: {
name: `${N}item1`,
unit: "ks",
item_number: `ITM001`,
},
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}item1`);
expect(body.data.unit).toBe("ks");
expect(body.data.is_active).toBe(true);
createdItemId = body.data.id;
});
it("lists items", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
});
it("gets item detail", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}item1`);
});
it("updates an item", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
payload: { name: `${N}item1_updated` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.name).toBe(`${N}item1_updated`);
});
it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => {
// The current API HARD-deletes the item — the route does
// `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The
// test name reflects that real behavior so a future intended switch to
// soft-delete would (correctly) make this test fail and prompt a review,
// rather than the old "soft delete" name masking the mismatch.
const res = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
// The row is actually gone — a subsequent GET 404s.
const checkRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
});
expect(checkRes.statusCode).toBe(404);
});
it("blocks unit change when batches exist", async () => {
// Create a fresh item
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: {
name: `${N}item_unit_change`,
unit: "ks",
},
});
const unitChangeItemId = itemRes.json().data.id;
// Create a location
const locRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "unit change loc" },
});
const unitChangeLocId = locRes.json().data.id;
// Create and confirm a receipt to create a batch
const receiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}receipt_for_unit_change`,
items: [
{
item_id: unitChangeItemId,
quantity: 5,
unit_price: 50,
location_id: unitChangeLocId,
},
],
},
});
const receiptId = receiptRes.json().data.id;
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptId}/confirm`,
headers: authHeader(adminToken),
});
// Try to change the unit — should be blocked
const updateRes = await app.inject({
method: "PUT",
url: `/api/admin/warehouse/items/${unitChangeItemId}`,
headers: authHeader(adminToken),
payload: { unit: "m" },
});
expect(updateRes.statusCode).toBe(409);
});
});
// ---------------------------------------------------------------------------
// 5. Receipt flow
// ---------------------------------------------------------------------------
describe("Receipt flow", () => {
let receiptItemId: number;
let receiptLocId: number;
beforeEach(async () => {
// Create a fresh item and location for each test
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_receipt_flow`, unit: "ks" },
});
receiptItemId = itemRes.json().data.id;
const locRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "receipt flow loc" },
});
receiptLocId = locRes.json().data.id;
});
it("creates receipt in DRAFT, confirms, then cancels (storno)", async () => {
// 1. Create receipt in DRAFT
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}receipt_flow_test`,
items: [
{
item_id: receiptItemId,
quantity: 20,
unit_price: 150,
location_id: receiptLocId,
},
],
},
});
expect(createRes.statusCode).toBe(201);
const receiptId = createRes.json().data.id;
expect(createRes.json().data.status).toBe("DRAFT");
// 2. Confirm receipt
const confirmRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptId}/confirm`,
headers: authHeader(adminToken),
});
expect(confirmRes.statusCode).toBe(200);
expect(confirmRes.json().data.receipt_number).toBeTruthy();
// 3. Verify batches were created
const batchesRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${receiptItemId}/batches`,
headers: authHeader(adminToken),
});
expect(batchesRes.statusCode).toBe(200);
const batches = batchesRes.json().data.batches;
expect(Array.isArray(batches)).toBe(true);
expect(batches.length).toBeGreaterThanOrEqual(1);
expect(Number(batches[0].quantity)).toBe(20);
// 4. Verify item_locations updated
const itemDetailRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${receiptItemId}`,
headers: authHeader(adminToken),
});
const itemLocations = itemDetailRes.json().data.item_locations;
const loc = itemLocations.find(
(il: { location_id: number }) => il.location_id === receiptLocId,
);
expect(loc).toBeDefined();
expect(Number(loc.quantity)).toBe(20);
// 5. Cancel receipt (storno)
const cancelRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptId}/cancel`,
headers: authHeader(adminToken),
});
expect(cancelRes.statusCode).toBe(200);
// 6. Verify reversed — check item available qty
const availRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${receiptItemId}/available-qty`,
headers: authHeader(adminToken),
});
expect(availRes.json().data.available_quantity).toBe(0);
});
it("rejects confirming a non-DRAFT receipt", async () => {
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}receipt_double_confirm`,
items: [
{
item_id: receiptItemId,
quantity: 5,
unit_price: 10,
location_id: receiptLocId,
},
],
},
});
const receiptId = createRes.json().data.id;
// Confirm once
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptId}/confirm`,
headers: authHeader(adminToken),
});
// Try to confirm again
const secondConfirm = await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptId}/confirm`,
headers: authHeader(adminToken),
});
expect(secondConfirm.statusCode).toBe(400);
});
it("cancels a DRAFT receipt (no stock reversal needed)", async () => {
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}receipt_cancel_draft`,
items: [
{
item_id: receiptItemId,
quantity: 3,
unit_price: 10,
},
],
},
});
const receiptId = createRes.json().data.id;
const cancelRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptId}/cancel`,
headers: authHeader(adminToken),
});
expect(cancelRes.statusCode).toBe(200);
// Verify receipt is CANCELLED
const detailRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}`,
headers: authHeader(adminToken),
});
expect(detailRes.json().data.status).toBe("CANCELLED");
});
});
// ---------------------------------------------------------------------------
// 5b. Receipt attachment download
// ---------------------------------------------------------------------------
describe("Receipt attachment download", () => {
let receiptId: number;
let otherReceiptId: number;
let attachmentId: number;
let diacriticAttachmentId: number;
const fileBytes = Buffer.from("%PDF-1.4 fake attachment bytes", "utf8");
const filePath = "Přijaté/2026/06/wh_test_priloha.pdf";
const diacriticFileName = "příloha č. 1.pdf";
beforeAll(async () => {
// Item + two receipts (attachment belongs to the first one only)
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_att_download`, unit: "ks" },
});
const itemId = itemRes.json().data.id;
const receiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}att_download_a`,
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
},
});
receiptId = receiptRes.json().data.id;
const otherReceiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}att_download_b`,
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
},
});
otherReceiptId = otherReceiptRes.json().data.id;
// Attachment row created directly — the upload endpoint needs a writable
// NAS, which is not available in the test environment. The NAS read is
// stubbed per-test on the singleton (same temp-dir/stub seam as the
// nas-document-manager tests); the DB rows and route logic stay real.
const attachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: receiptId,
file_name: "wh_test_priloha.pdf",
file_mime: "application/pdf",
file_size: fileBytes.length,
file_path: filePath,
},
});
attachmentId = attachment.id;
// Diacritic filename — the norm for a Czech company. Node throws on
// header values with chars > U+00FF, so this exercises the RFC 5987 path.
const diacriticAttachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: receiptId,
file_name: diacriticFileName,
file_mime: "application/pdf",
file_size: fileBytes.length,
file_path: "Přijaté/2026/06/wh_test_priloha_diakritika.pdf",
},
});
diacriticAttachmentId = diacriticAttachment.id;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("downloads an existing attachment with correct content-type and bytes", async () => {
const readSpy = vi
.spyOn(nasInvoicesManager, "readReceived")
.mockReturnValue({ data: fileBytes, fileName: "wh_test_priloha.pdf" });
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/pdf");
expect(res.headers["content-disposition"]).toBe(
`attachment; filename="wh_test_priloha.pdf"; filename*=UTF-8''wh_test_priloha.pdf`,
);
expect(res.rawPayload.equals(fileBytes)).toBe(true);
expect(readSpy).toHaveBeenCalledWith(filePath);
});
it("downloads an attachment with a Czech diacritic filename (RFC 5987)", async () => {
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue({
data: fileBytes,
fileName: diacriticFileName,
});
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${diacriticAttachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const disposition = res.headers["content-disposition"];
// The real UTF-8 name travels percent-encoded in filename* (RFC 5987) …
expect(disposition).toContain(
`filename*=UTF-8''${encodeURIComponent(diacriticFileName)}`,
);
// … alongside an ASCII-only fallback, so the header never carries
// chars > U+00FF (which Node's setHeader rejects → 500).
expect(disposition).toBe(
`attachment; filename="p__loha _. 1.pdf"; filename*=UTF-8''p%C5%99%C3%ADloha%20%C4%8D.%201.pdf`,
);
expect(res.rawPayload.equals(fileBytes)).toBe(true);
});
it("returns 404 when the attachment belongs to a different receipt", async () => {
const readSpy = vi.spyOn(nasInvoicesManager, "readReceived");
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${otherReceiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
// Ownership is rejected before any NAS access
expect(readSpy).not.toHaveBeenCalled();
});
it("returns 404 for a nonexistent attachment id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/99999999`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("returns 404 when the file is missing on the NAS", async () => {
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue(null);
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("rejects download without warehouse.view permission (403)", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(noPermToken),
});
expect(res.statusCode).toBe(403);
});
});
// ---------------------------------------------------------------------------
// 6. Issue flow
// ---------------------------------------------------------------------------
describe("Issue flow", () => {
let issueItemId: number;
let issueLocId: number;
let issueReceiptId: number;
beforeEach(async () => {
// Create item + location, then confirm a receipt to have stock
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_issue_flow`, unit: "ks" },
});
issueItemId = itemRes.json().data.id;
const locRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "issue flow loc" },
});
issueLocId = locRes.json().data.id;
// Create and confirm receipt to have stock
const receiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}receipt_for_issue`,
items: [
{
item_id: issueItemId,
quantity: 50,
unit_price: 100,
location_id: issueLocId,
},
],
},
});
issueReceiptId = receiptRes.json().data.id;
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${issueReceiptId}/confirm`,
headers: authHeader(adminToken),
});
});
it("creates issue, confirms, then cancels — batches decremented then restored", async () => {
// 1. Create issue (DRAFT)
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/issues",
headers: authHeader(adminToken),
payload: {
project_id: projectId,
notes: `${N}issue_flow_test`,
items: [
{
item_id: issueItemId,
quantity: 10,
location_id: issueLocId,
},
],
},
});
expect(createRes.statusCode).toBe(201);
const issueId = createRes.json().data.id;
expect(createRes.json().data.status).toBe("DRAFT");
// 2. Confirm issue
const confirmRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
headers: authHeader(adminToken),
});
expect(confirmRes.statusCode).toBe(200);
expect(confirmRes.json().data.issue_number).toBeTruthy();
// 3. Verify batches decremented
const batchesRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${issueItemId}/batches`,
headers: authHeader(adminToken),
});
const batches = batchesRes.json().data.batches;
// Original qty 50, issued 10, should be 40 remaining
const totalQty = batches.reduce(
(sum: number, b: { quantity: unknown }) => sum + Number(b.quantity),
0,
);
expect(totalQty).toBe(40);
// 4. Verify item_locations decremented
const itemDetailRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${issueItemId}`,
headers: authHeader(adminToken),
});
const loc = itemDetailRes
.json()
.data.item_locations.find(
(il: { location_id: number }) => il.location_id === issueLocId,
);
expect(Number(loc.quantity)).toBe(40);
// 5. Cancel issue
const cancelRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/issues/${issueId}/cancel`,
headers: authHeader(adminToken),
});
expect(cancelRes.statusCode).toBe(200);
// 6. Verify batches restored
const batchesAfterRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${issueItemId}/batches`,
headers: authHeader(adminToken),
});
const batchesAfter = batchesAfterRes.json().data.batches;
const totalQtyAfter = batchesAfter.reduce(
(sum: number, b: { quantity: unknown }) => sum + Number(b.quantity),
0,
);
expect(totalQtyAfter).toBe(50);
});
it("rejects issue when insufficient stock", async () => {
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/issues",
headers: authHeader(adminToken),
payload: {
project_id: projectId,
notes: `${N}issue_insufficient`,
items: [
{
item_id: issueItemId,
quantity: 999,
},
],
},
});
// Auto-FIFO resolution should fail at creation time
expect(createRes.statusCode).toBe(400);
});
});
// ---------------------------------------------------------------------------
// 6b. FIFO ordering — the OLDEST batch is consumed first
// ---------------------------------------------------------------------------
describe("Issue FIFO ordering (oldest batch first)", () => {
it("consumes the batch with the earliest received_at first, not the lowest id", async () => {
// Build two batches for one item via two confirmed receipts.
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_fifo`, unit: "ks" },
});
const fifoItemId = itemRes.json().data.id as number;
const locA = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc A" },
})
).json().data.id as number;
const locB = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc B" },
})
).json().data.id as number;
// Helper: create + confirm a receipt of `qty` at `locId`.
const confirmReceipt = async (qty: number, locId: number) => {
const r = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}fifo_receipt`,
items: [
{
item_id: fifoItemId,
quantity: qty,
unit_price: 10,
location_id: locId,
},
],
},
});
const id = r.json().data.id as number;
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${id}/confirm`,
headers: authHeader(adminToken),
});
};
// batchEarly is created FIRST (lower id); batchLate is created SECOND
// (higher id). We then flip their received_at so the HIGHER-id batch is the
// OLDEST — this is what lets the test prove FIFO sorts by received_at, not
// by insertion id.
await confirmReceipt(10, locA); // → batch with the lower id
await confirmReceipt(10, locB); // → batch with the higher id
const batchesRaw = await prisma.sklad_batches.findMany({
where: { item_id: fifoItemId },
orderBy: { id: "asc" },
});
expect(batchesRaw.length).toBe(2);
const lowerId = batchesRaw[0]; // created first
const higherId = batchesRaw[1]; // created second
// Make the HIGHER-id batch the OLDEST by received_at.
await prisma.sklad_batches.update({
where: { id: lowerId.id },
data: { received_at: new Date("2021-01-01T00:00:00Z") },
});
await prisma.sklad_batches.update({
where: { id: higherId.id },
data: { received_at: new Date("2020-01-01T00:00:00Z") }, // older
});
// Issue 5 with no explicit batch → FIFO must auto-select the OLDEST batch
// (the higher-id one we back-dated), partially depleting it.
const issueRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/issues",
headers: authHeader(adminToken),
payload: {
project_id: projectId,
notes: `${N}fifo_issue`,
items: [{ item_id: fifoItemId, quantity: 5 }],
},
});
expect(issueRes.statusCode).toBe(201);
const issueId = issueRes.json().data.id as number;
const confirmRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
headers: authHeader(adminToken),
});
expect(confirmRes.statusCode).toBe(200);
// The OLDEST batch (higher id, received 2020) is depleted from 10 → 5;
// the newer batch (lower id, received 2021) is untouched at 10. If FIFO
// wrongly sorted by id it would have consumed the lower-id batch instead.
const oldest = await prisma.sklad_batches.findUnique({
where: { id: higherId.id },
});
const newest = await prisma.sklad_batches.findUnique({
where: { id: lowerId.id },
});
expect(Number(oldest!.quantity)).toBe(5);
expect(Number(newest!.quantity)).toBe(10);
});
});
// ---------------------------------------------------------------------------
// 7. Reservation flow
// ---------------------------------------------------------------------------
describe("Reservation flow", () => {
let resItemId: number;
let resLocId: number;
beforeEach(async () => {
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_reservation`, unit: "ks" },
});
resItemId = itemRes.json().data.id;
const locRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "reservation loc" },
});
resLocId = locRes.json().data.id;
// Create and confirm receipt to have stock
const receiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}receipt_for_reservation`,
items: [
{
item_id: resItemId,
quantity: 100,
unit_price: 50,
location_id: resLocId,
},
],
},
});
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${receiptRes.json().data.id}/confirm`,
headers: authHeader(adminToken),
});
});
it("creates reservation — available_qty decreases, cancel restores it", async () => {
// Check initial available qty
const beforeRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${resItemId}/available-qty`,
headers: authHeader(adminToken),
});
const beforeQty = beforeRes.json().data.available_quantity;
expect(beforeQty).toBe(100);
// Create reservation
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/reservations",
headers: authHeader(adminToken),
payload: {
item_id: resItemId,
project_id: projectId,
quantity: 30,
notes: `${N}reservation_test`,
},
});
expect(createRes.statusCode).toBe(201);
expect(createRes.json().data.status).toBe("ACTIVE");
const reservationId = createRes.json().data.id;
// Verify available_qty decreased
const afterCreateRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${resItemId}/available-qty`,
headers: authHeader(adminToken),
});
expect(afterCreateRes.json().data.available_quantity).toBe(70);
// Cancel reservation
const cancelRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/reservations/${reservationId}/cancel`,
headers: authHeader(adminToken),
});
expect(cancelRes.statusCode).toBe(200);
// Verify available_qty restored
const afterCancelRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${resItemId}/available-qty`,
headers: authHeader(adminToken),
});
expect(afterCancelRes.json().data.available_quantity).toBe(100);
});
it("rejects reservation when available quantity is insufficient", async () => {
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/reservations",
headers: authHeader(adminToken),
payload: {
item_id: resItemId,
project_id: projectId,
quantity: 999,
notes: `${N}reservation_insufficient`,
},
});
expect(createRes.statusCode).toBe(400);
});
it("rejects cancelling an already cancelled reservation", async () => {
const createRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/reservations",
headers: authHeader(adminToken),
payload: {
item_id: resItemId,
project_id: projectId,
quantity: 5,
notes: `${N}reservation_double_cancel`,
},
});
const reservationId = createRes.json().data.id;
// Cancel once
await app.inject({
method: "POST",
url: `/api/admin/warehouse/reservations/${reservationId}/cancel`,
headers: authHeader(adminToken),
});
// Try to cancel again
const secondCancel = await app.inject({
method: "POST",
url: `/api/admin/warehouse/reservations/${reservationId}/cancel`,
headers: authHeader(adminToken),
});
expect(secondCancel.statusCode).toBe(400);
});
});
// ---------------------------------------------------------------------------
// 8. Permission checks
// ---------------------------------------------------------------------------
describe("Permission checks", () => {
it("rejects requests without authentication (401)", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/categories",
});
expect(res.statusCode).toBe(401);
});
it("rejects warehouse.manage endpoints without permission (403)", async () => {
const endpoints = [
{ method: "GET", url: "/api/admin/warehouse/categories" },
{ method: "POST", url: "/api/admin/warehouse/categories" },
{ method: "GET", url: "/api/admin/warehouse/suppliers" },
{ method: "GET", url: "/api/admin/warehouse/locations" },
{ method: "POST", url: "/api/admin/warehouse/items" },
];
for (const ep of endpoints) {
const res = await app.inject({
method: ep.method as "GET" | "POST",
url: ep.url,
headers: authHeader(noPermToken),
payload:
ep.method === "POST"
? { name: "x", code: "x", unit: "ks" }
: undefined,
});
expect(res.statusCode).toBe(403);
}
});
it("rejects warehouse.view endpoints without permission (403)", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/items",
headers: authHeader(noPermToken),
});
expect(res.statusCode).toBe(403);
});
it("rejects warehouse.operate endpoints without permission (403)", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(noPermToken),
payload: {
items: [{ item_id: 1, quantity: 1, unit_price: 1 }],
},
});
expect(res.statusCode).toBe(403);
});
it("rejects warehouse.inventory endpoints without permission (403)", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/warehouse/inventory-sessions",
headers: authHeader(noPermToken),
});
expect(res.statusCode).toBe(403);
});
});