Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1366 lines
42 KiB
TypeScript
1366 lines
42 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("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");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
});
|
|
});
|