文件
hyb-oa/server/tests/unit/routes/departments.errors.test.js
T
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

96 行
7.4 KiB
JavaScript

const Fastify = require("fastify");
const routes = require("../../../src/routes/departments");
const { AppError } = require("../../../src/utils/AppError");
const ID = "11111111-1111-4111-8111-111111111111";
const PARENT = "22222222-2222-4222-8222-222222222222";
const apps = [];
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
async function buildApp(allowed = true) {
const app = Fastify(); apps.push(app);
const current = { id: ID, name: "合成部门", parent_id: null, archived_at: null };
const parent = { id: PARENT, name: "合成上级", parent_id: null, archived_at: null };
const prisma = {
department: {
findUnique: vi.fn(async ({ where }) => where.id === ID ? current : parent),
findMany: vi.fn(async () => [current, parent]), count: vi.fn().mockResolvedValue(0),
create: vi.fn(async ({ data }) => ({ id: ID, ...data })), update: vi.fn(async ({ data }) => ({ ...current, ...data })),
},
employee: { count: vi.fn().mockResolvedValue(0) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
};
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
app.decorate("prisma", prisma);
app.decorate("authenticate", async (request) => { request.employee = { id: ID }; });
app.decorate("requirePermission", () => async (request) => { await app.authenticate(request); if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); });
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
await app.register(routes, { prefix: "/api" }); return { app, prisma, current, parent };
}
function expectNoWrites(prisma) { expect(prisma.department.create).not.toHaveBeenCalled(); expect(prisma.department.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }
function expectError(response, status, reasonCode, details) {
expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } });
}
it.each([
["GET", "/departments?includeArchived=invalid", undefined, "department.query_invalid"],
["POST", "/departments", { name: "" }, "department.create_input_invalid"],
["PATCH", `/departments/${ID}`, { parentId: "invalid" }, "department.update_input_invalid"],
])("explains %s %s input before database calls", async (method, path, payload, reasonCode) => {
const { app, prisma } = await buildApp();
const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) });
expect(response.statusCode).toBe(400); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode, formErrors: [], fieldErrors: expect.any(Object) } });
expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.department.findMany).not.toHaveBeenCalled(); expectNoWrites(prisma);
});
it.each(["PATCH", "POST"])("rejects invalid department identifiers for %s", async (method) => {
const { app, prisma } = await buildApp();
const response = await app.inject({ method, url: `/api/departments/bad${method === "POST" ? "/archive" : ""}`, ...(method === "PATCH" ? { payload: { name: "合成" } } : {}) });
expectError(response, 400, "department.id_invalid", { id: "invalid_uuid" }); expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma);
});
it("rejects an empty patch before starting a transaction", async () => {
const { app, prisma } = await buildApp();
expectError(await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: {} }), 400, "department.update_empty", { body: "empty" });
expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma);
});
it.each(["POST", "PATCH"].flatMap((method) => ["missing", "archived"].map((state) => [method, state])))("preserves %s parent %s refusal", async (method, state) => {
const { app, prisma, current, parent } = await buildApp();
if (state === "missing") prisma.department.findUnique.mockImplementation(async ({ where }) => where.id === ID ? current : null);
else parent.archived_at = new Date("2024-01-01T00:00:00Z");
const response = await app.inject({ method, url: `/api/departments${method === "PATCH" ? `/${ID}` : ""}`, payload: { name: "合成", parentId: PARENT } });
expectError(response, state === "missing" ? 400 : 409, state === "missing" ? "department.parent_not_found" : "department.parent_archived", { parentId: state === "missing" ? "not_found" : "archived" }); expectNoWrites(prisma);
});
it.each([
["archived", "department.archived_locked", { status: "archived" }],
["self", "department.parent_self_forbidden", { parentId: "self_not_allowed" }],
["cycle", "department.parent_cycle_forbidden", { parentId: "cycle_not_allowed" }],
])("preserves the %s hierarchy guard inside the transaction", async (state, reason, details) => {
const { app, prisma, current, parent } = await buildApp();
if (state === "archived") current.archived_at = new Date("2024-01-01T00:00:00Z");
if (state === "cycle") parent.parent_id = ID;
const response = await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: { parentId: state === "self" ? ID : PARENT } });
expectError(response, 409, reason, details); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expectNoWrites(prisma);
});
it.each([
["department", "department.children_exist", "has_children"],
["employee", "department.active_employees_exist", "has_active_employees"],
])("preserves %s references blocking archival", async (model, reason, detail) => {
const { app, prisma } = await buildApp(); prisma[model].count.mockResolvedValue(1);
expectError(await app.inject({ method: "POST", url: `/api/departments/${ID}/archive` }), 409, reason, { department: detail }); expectNoWrites(prisma);
});
it("preserves archive idempotency and missing-department 404", async () => {
const { app, prisma, current } = await buildApp(); current.archived_at = new Date("2024-01-01T00:00:00Z");
const archived = await app.inject({ method: "POST", url: `/api/departments/${ID}/archive` });
expect(archived.statusCode).toBe(200); expect(archived.json().data.outcome).toBe("already_archived"); expect(prisma.department.count).not.toHaveBeenCalled(); expectNoWrites(prisma);
prisma.department.findUnique.mockResolvedValue(null);
expect((await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: { name: "合成" } })).statusCode).toBe(404); expectNoWrites(prisma);
});
it("preserves normal create, parent clearing and archive audit", async () => {
const { app, prisma } = await buildApp();
expect((await app.inject({ method: "POST", url: "/api/departments", payload: { name: "合成部门", parentId: PARENT } })).statusCode).toBe(200);
expect((await app.inject({ method: "PATCH", url: `/api/departments/${ID}`, payload: { parentId: null } })).statusCode).toBe(200);
expect(prisma.department.update.mock.calls[0][0].data.parent_id).toBeNull();
const archived = await app.inject({ method: "POST", url: `/api/departments/${ID}/archive` });
expect(archived.statusCode).toBe(200); expect(archived.json().data.outcome).toBe("archived"); expect(prisma.auditLog.create).toHaveBeenCalledTimes(3);
});
it("preserves permission refusal before create validation", async () => {
const { app, prisma } = await buildApp(false);
expect((await app.inject({ method: "POST", url: "/api/departments", payload: {} })).statusCode).toBe(403); expect(prisma.$transaction).not.toHaveBeenCalled(); expectNoWrites(prisma);
});