Files
app/src/__tests__/warehouse.test.ts
BOHA 5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00

1245 lines
38 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } 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 { 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("deactivates an item (soft delete)", async () => {
const res = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
});
// The current API hard-deletes the item (the route does
// `prisma.sklad_items.delete`, not an `is_active=false` toggle).
// Verify the row is gone rather than checking is_active.
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
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");
});
});
// ---------------------------------------------------------------------------
// 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);
});
});
// ---------------------------------------------------------------------------
// 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);
});
});