const { enableNumbering } = require("../helpers/activeNumbering"); const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const notifications = require("../../../src/services/notifications"); const email = require("../../../src/services/email"); const dispatcher = require("../../../src/services/notificationDispatcher"); const collaborators = [[notifications, "createNotificationsWithRecords"], [notifications, "resolveRecipientsForAssignee"], [email, "sendNotificationEmailSafe"], [dispatcher, "flushNotificationDispatchTasks"]]; const saved = collaborators.map(([object, key]) => object[key]); const mocks = {}; collaborators.forEach(([object, key]) => { object[key] = mocks[key] = vi.fn(); }); const paths = [require.resolve("../../../src/routes/assetMovements"), require.resolve("../../../src/routes/inventoryChecks")]; const savedRoutes = paths.map((path) => require.cache[path]); const routes = paths.map((path) => { delete require.cache[path]; return require(path); }); collaborators.forEach(([object, key], i) => { object[key] = saved[i]; }); afterAll(() => paths.forEach((path, i) => { if (savedRoutes[i]) require.cache[path] = savedRoutes[i]; else delete require.cache[path]; })); const ID = "11111111-1111-4111-8111-111111111111"; const PERSON = "22222222-2222-4222-8222-222222222222"; const OTHER = "33333333-3333-4333-8333-333333333333"; const REF = "44444444-4444-4444-8444-444444444444"; const apps = []; beforeEach(() => { Object.values(mocks).forEach((fn) => fn.mockReset().mockResolvedValue([])); }); afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); async function build({ allowed = true } = {}) { const app = Fastify(); apps.push(app); const asset = { id: ID, asset_no: "SYNTHETIC", name: "合成资产", status: "in_stock", assignee_employee_id: null, location: "合成甲地", legal_entity_id: REF, cost_center_id: REF, created_at: new Date("2026-01-01T00:00:00Z") }; const check = { id: ID, check_no: "SYNTHETIC-CHECK", scope_type: "all", scope_ref_id: null, status: "draft", created_by: PERSON, items: [] }; const model = (row) => ({ findUnique: vi.fn(async () => row), findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn(async () => [row]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => Object.assign(row, data)) }); const prisma = { assetCard: model(asset), inventoryCheck: model(check), employee: model({ id: OTHER, status: "active", name: "合成人员" }), assetAssignment: model({ id: REF, employee_id: PERSON, asset_id: ID }), assetTransfer: model({ id: REF }), legalEntity: model({ id: REF }), costCenter: model({ id: REF }), auditLog: model({ id: REF }), inventoryCheckItem: { upsert: vi.fn().mockResolvedValue({}) }, documentCategory: { findFirst: vi.fn().mockResolvedValue(null) }, businessNumberPolicy: { findUnique: vi.fn(async () => null) }, $executeRaw: vi.fn(async () => 0), documentSequence: { findUnique: vi.fn().mockResolvedValue(null), upsert: vi.fn(async ({ create }) => ({ ...create, current_number: 1 })) }, }; prisma.$transaction = vi.fn(async (cb) => cb(prisma)); app.decorate("prisma", prisma); app.decorate("config", {}); app.decorate("requirePermission", (permission) => async (request) => { request.employee = { id: PERSON }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限", details: { permission } }); }); app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } })); for (const route of routes) await app.register(route, { prefix: "/api" }); return { app, prisma, asset, check }; } function rejected(response, reason, status, details = {}) { expect(response.statusCode).toBe(status); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason, ...details } }); } function noWrites(prisma) { for (const [name, model] of Object.entries(prisma)) for (const op of ["create", "update", "upsert"]) if (model[op]) expect(model[op], `${name}.${op}`).not.toHaveBeenCalled(); for (const fn of Object.values(mocks)) expect(fn).not.toHaveBeenCalled(); } const move = (app, op, body, id = ID) => app.inject({ method: "POST", url: `/api/assets/${id}/${op}`, payload: body }); const inventory = (app, op, body, id = ID) => app.inject({ method: "POST", url: `/api/inventory-checks${op ? `/${id}/${op}` : ""}`, payload: body }); describe("asset movement rejection contracts", () => { it.each(["assign", "return", "transfer"])("explains invalid id for %s before transaction", async (op) => { const { app, prisma } = await build(); rejected(await move(app, op, {}, "bad"), "asset_movement.id_invalid", 400, { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); it.each([["assign", {}], ["return", { unexpected: true }], ["transfer", { toEmployeeId: "bad" }]])("explains %s schema", async (op, body) => { const { app, prisma } = await build(); rejected(await move(app, op, body), `asset_movement.${op}_input_invalid`, 400, { fieldErrors: expect.any(Object) }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); it.each(["assign", "return", "transfer"].flatMap((op) => ["retired", "archived"].map((status) => [op, status])))("keeps %s forbidden for %s", async (op, status) => { const { app, prisma, asset } = await build(); asset.status = status; rejected(await move(app, op, op === "assign" ? { employeeId: OTHER } : {}), "asset_movement.status_blocked", 409, { status: "movement_not_allowed" }); noWrites(prisma); }); it.each(["assign", "return", "transfer"])("keeps %s missing asset 404", async (op) => { const { app, prisma } = await build(); prisma.assetCard.findUnique.mockResolvedValue(null); rejected(await move(app, op, op === "assign" ? { employeeId: OTHER } : {}), "asset_movement.not_found", 404, { id: "not_found" }); noWrites(prisma); }); it.each(["assignee", "open_assignment"])("rejects existing %s before employee or mutation", async (kind) => { const { app, prisma, asset } = await build(); if (kind === "assignee") asset.assignee_employee_id = PERSON; else prisma.assetAssignment.findFirst.mockResolvedValue({ id: REF }); rejected(await move(app, "assign", { employeeId: OTHER }), "asset_movement.already_assigned", 409, { asset: "already_assigned" }); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); noWrites(prisma); }); it.each(["assign", "transfer"].flatMap((op) => [null, { id: OTHER, status: "inactive" }].map((row) => [op, row])))("explains %s receiving employee %s", async (op, row) => { const { app, prisma } = await build(); prisma.employee.findUnique.mockResolvedValue(row); const field = op === "assign" ? "employeeId" : "toEmployeeId"; rejected(await move(app, op, { [field]: OTHER }), `asset_movement.employee_${row ? "inactive" : "not_found"}`, row ? 409 : 404, { [field]: row ? "inactive" : "not_found" }); noWrites(prisma); }); it("rejects unassigned return", async () => { const { app, prisma } = await build(); rejected(await move(app, "return", {}), "asset_movement.not_assigned", 409, { asset: "not_assigned" }); noWrites(prisma); }); it("directs clearing an assignee to return", async () => { const { app, prisma, asset } = await build(); asset.assignee_employee_id = PERSON; rejected(await move(app, "transfer", { toEmployeeId: null }), "asset_movement.use_return", 409, { toEmployeeId: "use_return" }); noWrites(prisma); }); it.each([{}, { toLocation: "合成甲地" }, { toEmployeeId: null }])("rejects no-change transfer %s", async (body) => { const { app, prisma } = await build(); rejected(await move(app, "transfer", body), "asset_movement.no_change", 409, { transfer: "no_change" }); noWrites(prisma); }); it("preserves assign, return and transfer effects with notification stubs", async () => { const { app, prisma, asset } = await build(); expect((await move(app, "assign", { employeeId: OTHER, note: " 合成领用 " })).statusCode).toBe(200); expect(asset).toMatchObject({ assignee_employee_id: OTHER, status: "in_use" }); expect(prisma.assetAssignment.create.mock.calls[0][0].data.note).toBe("合成领用"); expect((await move(app, "return", {})).statusCode).toBe(200); expect(asset).toMatchObject({ assignee_employee_id: null, status: "in_stock" }); // No open assignment in this synthetic DB; the original fallback creates and closes it. expect(prisma.assetAssignment.create).toHaveBeenCalledTimes(2); expect(prisma.assetAssignment.update).toHaveBeenCalledOnce(); expect((await move(app, "transfer", { toEmployeeId: OTHER, toLocation: " 合成乙地 " })).statusCode).toBe(200); expect(asset).toMatchObject({ assignee_employee_id: OTHER, location: "合成乙地", status: "in_use" }); expect(prisma.assetTransfer.create).toHaveBeenCalledOnce(); expect(mocks.createNotificationsWithRecords).toHaveBeenCalledTimes(3); expect(mocks.sendNotificationEmailSafe).toHaveBeenCalledTimes(3); expect(mocks.flushNotificationDispatchTasks).toHaveBeenCalledTimes(3); expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toEqual(["ASSET_ASSIGNED", "ASSET_RETURNED", "ASSET_TRANSFERRED"]); }); it("preserves location-only transfer without closing assignment", async () => { const { app, prisma, asset } = await build(); asset.assignee_employee_id = PERSON; asset.status = "under_repair"; expect((await move(app, "transfer", { toLocation: null })).statusCode).toBe(200); expect(asset).toMatchObject({ assignee_employee_id: PERSON, status: "under_repair", location: null }); expect(prisma.assetAssignment.create).not.toHaveBeenCalled(); expect(prisma.assetAssignment.update).not.toHaveBeenCalled(); }); }); describe("inventory rejection contracts", () => { it("rejects bad list query before access", async () => { const { app, prisma } = await build(); rejected(await app.inject({ method: "GET", url: "/api/inventory-checks?status=bad" }), "inventory_check.query_invalid", 400); expect(prisma.inventoryCheck.findMany).not.toHaveBeenCalled(); noWrites(prisma); }); it.each([["", {} , "create_input_invalid"], ["items", { items: [] }, "record_input_invalid"]])("rejects %s schema", async (op, body, reason) => { const { app, prisma } = await build(); rejected(await inventory(app, op, body), `inventory_check.${reason}`, 400); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); it.each(["start", "items", "complete"])("explains invalid %s id", async (op) => { const { app, prisma } = await build(); rejected(await inventory(app, op, {}, "bad"), "inventory_check.id_invalid", 400, { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); it.each(["legal_entity", "cost_center", "location"])("requires scope value for %s", async (scopeType) => { const { app, prisma } = await build(); rejected(await inventory(app, "", { scopeType }), "inventory_check.scope_required", 400, { scopeRefId: "required" }); noWrites(prisma); }); it.each(["legal_entity", "cost_center"])("validates UUID scope %s", async (scopeType) => { const { app, prisma } = await build(); rejected(await inventory(app, "", { scopeType, scopeRefId: "bad" }), "inventory_check.scope_id_invalid", 400, { scopeRefId: "invalid_uuid" }); noWrites(prisma); }); it.each([["legal_entity", "legalEntity", "company_not_found"], ["cost_center", "costCenter", "cost_center_not_found"]])("explains missing %s scope", async (scopeType, model, reason) => { const { app, prisma } = await build(); prisma[model].findUnique.mockResolvedValue(null); rejected(await inventory(app, "", { scopeType, scopeRefId: REF }), `inventory_check.${reason}`, 404, { scopeRefId: "not_found" }); noWrites(prisma); }); it.each(["start", "items", "complete"])("explains missing %s check", async (op) => { const { app, prisma } = await build(); prisma.inventoryCheck.findUnique.mockResolvedValue(null); rejected(await inventory(app, op, op === "items" ? { items: [{ assetId: ID, result: "matched" }] } : undefined), "inventory_check.not_found", 404, { id: "not_found" }); noWrites(prisma); }); it.each([["start", "counting", "start", "draft"], ["items", "draft", "record", "counting"], ["complete", "completed", "complete", "counting"]])("explains %s state %s", async (op, status, reason, expected) => { const { app, prisma, check } = await build(); check.status = status; rejected(await inventory(app, op, op === "items" ? { items: [{ assetId: ID, result: "matched" }] } : undefined), `inventory_check.${reason}_state_invalid`, 409, { status: `expected_${expected}` }); noWrites(prisma); }); it.each(["legal_entity", "cost_center", "location"])("rejects asset outside %s", async (scopeType) => { const { app, prisma, check } = await build(); Object.assign(check, { status: "counting", scope_type: scopeType, scope_ref_id: OTHER }); rejected(await inventory(app, "items", { items: [{ assetId: ID, result: "matched" }] }), "inventory_check.asset_scope_mismatch", 409, { assetId: "scope_mismatch" }); noWrites(prisma); }); it("rejects missing first asset without writes", async () => { const { app, prisma, check } = await build(); check.status = "counting"; prisma.assetCard.findUnique.mockResolvedValue(null); rejected(await inventory(app, "items", { items: [{ assetId: ID, result: "matched" }] }), "inventory_check.asset_not_found", 404, { assetId: "not_found" }); noWrites(prisma); }); it.each(["duplicate", "missing", "scope"])("retains first upsert before later %s rejection", async (kind) => { const { app, prisma, check, asset } = await build(); check.status = "counting"; if (kind === "missing") prisma.assetCard.findUnique.mockResolvedValueOnce(asset).mockResolvedValueOnce(null); if (kind === "scope") { Object.assign(check, { scope_type: "legal_entity", scope_ref_id: REF }); prisma.assetCard.findUnique.mockResolvedValueOnce(asset).mockResolvedValueOnce({ ...asset, id: OTHER, legal_entity_id: OTHER }); } const reason = kind === "duplicate" ? "duplicate_asset" : kind === "missing" ? "asset_not_found" : "asset_scope_mismatch"; rejected(await inventory(app, "items", { items: [{ assetId: ID, result: "matched" }, { assetId: kind === "duplicate" ? ID : OTHER, result: "missing" }] }), `inventory_check.${reason}`, kind === "missing" ? 404 : 409); expect(prisma.inventoryCheckItem.upsert).toHaveBeenCalledOnce(); expect(prisma.inventoryCheck.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); for (const fn of Object.values(mocks)) expect(fn).not.toHaveBeenCalled(); }); it.each([["all", "ignored", null], ["location", " 合成位置 ", "合成位置"], ["legal_entity", REF, REF], ["cost_center", REF, REF]])("retains create %s scope", async (scopeType, scopeRefId, expected) => { const { app, prisma } = await build(); const r = await inventory(app, "", { scopeType, scopeRefId }); expect(r.statusCode).toBe(200); expect(prisma.inventoryCheck.create.mock.calls[0][0].data).toMatchObject({ scope_type: scopeType, scope_ref_id: expected }); expect(prisma.documentSequence.upsert).toHaveBeenCalledOnce(); }); it("retains start, record and complete with mock notification collaborators", async () => { const { app, prisma, check } = await build(); expect((await inventory(app, "start")).statusCode).toBe(200); expect(check.status).toBe("counting"); expect((await inventory(app, "items", { items: [{ assetId: ID, result: "damaged", note: " 合成备注 " }] })).statusCode).toBe(200); expect(prisma.inventoryCheckItem.upsert.mock.calls[0][0]).toMatchObject({ update: { result: "damaged", note: "合成备注" }, create: { inventory_check_id: ID, asset_id: ID } }); expect((await inventory(app, "complete")).statusCode).toBe(200); expect(check.status).toBe("completed"); expect(prisma.auditLog.create.mock.calls.map(([x]) => x.data.action)).toEqual(["INVENTORY_CHECK_STARTED", "INVENTORY_ITEM_RECORDED", "INVENTORY_CHECK_COMPLETED"]); expect(mocks.createNotificationsWithRecords).toHaveBeenCalledOnce(); expect(mocks.sendNotificationEmailSafe).toHaveBeenCalledOnce(); expect(mocks.flushNotificationDispatchTasks).toHaveBeenCalledOnce(); }); it.each(["/assets/bad/assign", "/inventory-checks/bad/start"])("keeps permission first for %s", async (url) => { const { app, prisma } = await build({ allowed: false }); const r = await app.inject({ method: "POST", url: `/api${url}`, payload: {} }); expect(r.statusCode).toBe(403); expect(r.json().error.code).toBe("PERM_001"); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); }); }); it("creates an annual inventory number under active policy", async () => { const { app, prisma } = await build(); enableNumbering(prisma); const response = await inventory(app, "", { scopeType: "all" }); expect(response.statusCode).toBe(200); expect(prisma.inventoryCheck.create.mock.calls[0][0].data.check_no).toMatch(/^PD-\d{4}-0001$/); expect(prisma.documentSequence.upsert).not.toHaveBeenCalled(); });