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
1245 lines
38 KiB
TypeScript
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);
|
|
});
|
|
});
|