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>; 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); }); });