From dac45baaa87b4a48fe329e208645e872ac631e6b Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 29 May 2026 14:41:15 +0200 Subject: [PATCH] test(warehouse): add integration tests Co-Authored-By: Claude Opus 4.8 --- src/__tests__/warehouse.test.ts | 1242 +++++++++++++++++++++++++++++++ 1 file changed, 1242 insertions(+) create mode 100644 src/__tests__/warehouse.test.ts diff --git a/src/__tests__/warehouse.test.ts b/src/__tests__/warehouse.test.ts new file mode 100644 index 0000000..8c639f6 --- /dev/null +++ b/src/__tests__/warehouse.test.ts @@ -0,0 +1,1242 @@ +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`, + lines: [ + { + 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), + }); + 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/items/${createdItemId}`, + headers: authHeader(adminToken), + }); + expect(checkRes.json().data.is_active).toBe(false); + }); + + 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`, + lines: [ + { + 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`, + lines: [ + { + 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; + 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`, + lines: [ + { + 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`, + lines: [ + { + 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`, + lines: [ + { + 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`, + lines: [ + { + 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; + // 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; + 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`, + lines: [ + { + 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`, + lines: [ + { + 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: { + lines: [{ 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); + }); +});