文件
hyb-oa/server/tests/unit/routes/asset-operations.errors.test.js
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

122 行
17 KiB
JavaScript

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