文件
hyb-oa/server/tests/unit/routes/approval-config.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

60 行
11 KiB
JavaScript

const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const workflowRoutes = require("../../../src/routes/workflowDefinitions");
const ruleRoutes = require("../../../src/routes/approvalRules");
const ID = "11111111-1111-4111-8111-111111111111";
const ACTOR = "22222222-2222-4222-8222-222222222222";
const STEP = { id: "synthetic", name: "合成审批", assigneeRule: { type: "role", value: "finance" } };
const DEFINITION = { entityType: "leave", name: "合成流程", steps: [STEP] };
const RULE = { name: "合成规则", minAmount: "0.00", maxAmount: "100.00", level1Role: "manager" };
const apps = [];
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
async function build({ allowed = true, enabled = true } = {}) {
const app = Fastify(); apps.push(app);
const definition = { id: ID, entity_type: "leave", name: "合成流程", version: 2, is_active: false, archived_at: null, published_at: null, definition_json: { steps: [{ id: STEP.id, name: STEP.name, assignee_rule: STEP.assigneeRule }] } };
const rule = { id: ID, name: "合成规则", min_amount: "0.00", max_amount: "100.00", level1_role: "manager", level2_role: "ceo", level3_role: "finance", owner_self_fallback_role: "finance", enabled: true, priority: 2, created_at: new Date("2026-01-01T00:00:00Z"), updated_at: new Date("2026-01-01T00:00:00Z") };
const model = (row) => ({ findUnique: vi.fn(async () => row), findFirst: vi.fn(async () => row), findMany: vi.fn(async () => [row]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => Object.assign(row, data)), updateMany: vi.fn().mockResolvedValue({ count: 1 }), aggregate: vi.fn().mockResolvedValue({ _max: { priority: 2 } }) });
const prisma = { workflowDefinition: model(definition), approvalRule: model(rule), auditLog: { create: vi.fn().mockResolvedValue({}) }, $queryRaw: vi.fn().mockResolvedValue([{ id: ID }]), featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: enabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [], version: 1 }) } };
prisma.$transaction = vi.fn(async (cb) => cb(prisma));
app.decorate("prisma", prisma); app.decorate("config", { workflowDefinitionAdminEnabled: true });
app.decorate("requirePermission", () => async (request) => { request.employee = { id: ACTOR }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); });
app.setErrorHandler((error, _, reply) => reply.status(error instanceof AppError ? error.statusCode : 500).send({ error: { code: error.code, details: error.details ?? {} } }));
await app.register(workflowRoutes, { prefix: "/api" }); await app.register(ruleRoutes, { prefix: "/api" });
return { app, prisma, definition, rule };
}
function noWrites(prisma) { for (const model of Object.values(prisma)) for (const op of ["create", "update", "updateMany"]) if (model[op]) expect(model[op]).not.toHaveBeenCalled(); }
function rejected(r, reason, status, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: reason, ...details } }); }
const inject = (app, method, path, payload) => app.inject({ method, url: `/api${path}`, payload });
describe("workflow definition error contracts", () => {
it.each([["POST", "", {} , "create_input_invalid"], ["PATCH", `/${ID}`, {}, "update_input_invalid"], ["POST", "", { ...DEFINITION, steps: Array.from({ length: 21 }, () => STEP) }, "create_input_invalid"]])("explains %s %s input", async (method, suffix, payload, reason) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/workflow-definitions${suffix}`, payload), `workflow_definition.${reason}`, 400); expect(prisma.workflowDefinition.findUnique).not.toHaveBeenCalled(); noWrites(prisma); });
it.each([["GET", ""], ["PATCH", ""], ["POST", "/publish"], ["POST", "/archive"]])("explains %s invalid id %s", async (method, suffix) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/workflow-definitions/bad${suffix}`, method === "PATCH" ? {} : undefined), "workflow_definition.id_invalid", 400, { id: "invalid_uuid" }); noWrites(prisma); });
it("explains invalid active entity", async () => { const { app, prisma } = await build(); rejected(await inject(app, "GET", "/workflow-definitions/entity/bad/active"), "workflow_definition.entity_type_invalid", 400, { entityType: "invalid" }); expect(prisma.workflowDefinition.findFirst).not.toHaveBeenCalled(); noWrites(prisma); });
it.each(["POST", "PATCH", "publish", "archive"])("preserves reimbursement projection write guard %s", async (operation) => { const { app, prisma, definition } = await build(); definition.entity_type = "reimbursement"; const method = operation === "PATCH" ? "PATCH" : "POST"; const suffix = operation === "POST" ? "" : `/${ID}${["publish", "archive"].includes(operation) ? `/${operation}` : ""}`; rejected(await inject(app, method, `/workflow-definitions${suffix}`, operation === "POST" ? { ...DEFINITION, entityType: "reimbursement" } : operation === "PATCH" ? { name: "合成" } : undefined), "workflow_definition.reimbursement_readonly", 409, { entityType: "unsupported_entity_type" }); noWrites(prisma); });
it.each(["published", "archived"])("explains readonly %s definition", async (status) => { const { app, prisma, definition } = await build(); if (status === "archived") definition.archived_at = new Date("2026-01-01"); else { definition.published_at = new Date("2026-01-01"); definition.is_active = true; } rejected(await inject(app, "PATCH", `/workflow-definitions/${ID}`, { notes: "合成" }), "workflow_definition.not_editable", 409, { status: "published_or_archived_readonly" }); noWrites(prisma); });
it("rejects archived publish before deactivating siblings", async () => { const { app, prisma, definition } = await build(); definition.archived_at = new Date("2026-01-01"); rejected(await inject(app, "POST", `/workflow-definitions/${ID}/publish`), "workflow_definition.archived_not_publishable", 409, { status: "archived_not_publishable" }); noWrites(prisma); });
it("preserves feature and permission priority", async () => {
const disabled = await build({ enabled: false }); rejected(await inject(disabled.app, "POST", "/workflow-definitions", {}), "workflow_definition.feature_disabled", 409, { featureFlag: "workflow_definition_admin_v1_disabled" }); noWrites(disabled.prisma); expect(disabled.prisma.workflowDefinition.findFirst).not.toHaveBeenCalled();
const denied = await build({ allowed: false }); expect((await inject(denied.app, "POST", "/workflow-definitions", {})).statusCode).toBe(403); expect(denied.prisma.featureFlag.findUnique).not.toHaveBeenCalled();
});
it("retains projection, 20-step creation, inactive-version edit, publish and archive", async () => {
const { app, prisma, definition } = await build();
const projection = await inject(app, "GET", "/workflow-definitions/reimbursement-derived"); expect(projection.statusCode).toBe(200); expect(projection.json().data.item).toMatchObject({ source: "approval_rules_projection", status: "derived_readonly" });
expect((await inject(app, "POST", "/workflow-definitions", { ...DEFINITION, steps: Array.from({ length: 20 }, (_, i) => ({ ...STEP, id: `synthetic-${i}` })) })).statusCode).toBe(201); expect(prisma.workflowDefinition.create.mock.calls[0][0].data).toMatchObject({ version: 3, is_active: false });
definition.published_at = new Date("2026-01-01"); definition.is_active = false;
expect((await inject(app, "PATCH", `/workflow-definitions/${ID}`, { notes: "合成说明" })).statusCode).toBe(200); expect(definition.definition_json.steps[0].assignee_rule).toEqual(STEP.assigneeRule);
expect((await inject(app, "POST", `/workflow-definitions/${ID}/publish`)).statusCode).toBe(200); expect(prisma.workflowDefinition.updateMany).toHaveBeenCalledWith({ where: { entity_type: "leave", id: { not: ID }, is_active: true }, data: { is_active: false, updated_by: ACTOR } });
expect((await inject(app, "POST", `/workflow-definitions/${ID}/archive`)).statusCode).toBe(200); expect(definition).toMatchObject({ is_active: false, archived_at: expect.any(Date) });
// The existing definition route does not add audits; migration does not invent them.
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("approval rule error contracts", () => {
it.each([["POST", "", {}, "create_input_invalid"], ["PATCH", `/${ID}`, { level1Role: "admin" }, "update_input_invalid"], ["PATCH", `/${ID}`, {}, "empty_update"], ["PATCH", "/bad", {}, "id_invalid"]])("explains %s %s rule input", async (method, suffix, payload, reason) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/approval-rules${suffix}`, payload), `approval_rule.${reason}`, 400); expect(prisma.$transaction).not.toHaveBeenCalled(); noWrites(prisma); });
it.each([["POST", { ...RULE, minAmount: "9".repeat(400) }, "amount_invalid", { amount: "invalid" }], ["POST", { ...RULE, maxAmount: "0.00" }, "amount_range_invalid", { maxAmount: "must_be_greater_than_minAmount" }], ["PATCH", { minAmount: "100.00" }, "amount_range_invalid", { maxAmount: "must_be_greater_than_minAmount" }], ["PATCH", { maxAmount: "9".repeat(400) }, "amount_invalid", { amount: "invalid" }]])("explains %s range %s", async (method, payload, reason, detail) => { const { app, prisma } = await build(); rejected(await inject(app, method, `/approval-rules${method === "PATCH" ? `/${ID}` : ""}`, payload), `approval_rule.${reason}`, 400, detail); noWrites(prisma); });
it("preserves max null, fallback default, priority and locked update audit", async () => {
const { app, prisma, rule } = await build(); const create = await inject(app, "POST", "/approval-rules", { ...RULE, maxAmount: null }); expect(create.statusCode).toBe(200); expect(prisma.approvalRule.create.mock.calls[0][0].data).toMatchObject({ max_amount: null, owner_self_fallback_role: "finance", priority: 3 });
expect((await inject(app, "PATCH", `/approval-rules/${ID}`, { minAmount: "200.00", maxAmount: null, ownerSelfFallbackRole: null })).statusCode).toBe(200); expect(rule).toMatchObject({ min_amount: "200.00", max_amount: null, owner_self_fallback_role: null }); expect(prisma.$queryRaw).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledTimes(2);
});
it("keeps rule permission and missing-lock 404", async () => { const denied = await build({ allowed: false }); expect((await inject(denied.app, "POST", "/approval-rules", {})).statusCode).toBe(403); noWrites(denied.prisma); const missing = await build(); missing.prisma.$queryRaw.mockResolvedValue([]); expect((await inject(missing.app, "PATCH", `/approval-rules/${ID}`, { name: "合成" })).statusCode).toBe(404); noWrites(missing.prisma); });
});