const workflowUtils = require("../../../src/utils/workflow"); const { resolveActionableStep } = workflowUtils; const OWNER_ID = "11111111-1111-4111-8111-111111111111"; const MANAGER_ID = "22222222-2222-4222-8222-222222222222"; const FINANCE_MGR_ID = "33333333-3333-4333-8333-333333333333"; function buildSteps() { return [ { id: "approval_1_manager", name: "主管审批", type: "approval", assignee_rule: { type: "department_manager" }, }, { id: "approval_2_finance", name: "财务复核", type: "approval", assignee_rule: { type: "role", value: "finance" }, }, ]; } function buildTx({ ownerRoles = ["ceo"], managerId = null, fallbackHasHolder = true, } = {}) { return { employee: { findUnique: vi.fn(async ({ where }) => { if (where.id === OWNER_ID) { return { id: OWNER_ID, direct_manager_id: managerId, roles: ownerRoles.map((role_code) => ({ role_code })), }; } if (where.id === MANAGER_ID) { return { id: MANAGER_ID, direct_manager_id: null, roles: [{ role_code: "manager" }], }; } return null; }), }, employeeRole: { findFirst: vi.fn(async () => ( fallbackHasHolder ? { employee_id: FINANCE_MGR_ID } : null )), }, }; } describe("workflow assignee resolution", () => { describe("parseWorkflowSteps", () => { it("throws error for invalid steps array", () => { expect(() => workflowUtils.parseWorkflowSteps({})).toThrow(/参数校验失败/); expect(() => workflowUtils.parseWorkflowSteps({ steps: [] })).toThrow(/参数校验失败/); }); it("parses valid steps array", () => { expect(workflowUtils.parseWorkflowSteps({ steps: [{ id: 1 }] })).toEqual([{ id: 1 }]); }); }); describe("buildWorkflowStepsFromApprovalRule", () => { it("builds steps correctly for valid rule", () => { const result = workflowUtils.buildWorkflowStepsFromApprovalRule({ level1_role: "manager", level2_role: "finance", }); expect(result.length).toBe(2); expect(result[0].assignee_rule.type).toBe("department_manager"); expect(result[1].assignee_rule.type).toBe("role"); expect(result[1].assignee_rule.value).toBe("finance"); }); it("throws if rule is empty or missing roles", () => { expect(() => workflowUtils.buildWorkflowStepsFromApprovalRule({})).toThrow(/参数校验失败/); }); it.each(["admin", "system_admin"])( "rejects protected system role %s in approval rules", (roleCode) => { expect(() => workflowUtils.buildWorkflowStepsFromApprovalRule({ level1_role: roleCode })) .toThrow(/参数校验失败/); }, ); }); describe("getWorkflowStepsSnapshot", () => { it.each(["admin", "system_admin"])( "rejects protected system role %s in historical snapshots", (roleCode) => { expect(() => workflowUtils.getWorkflowStepsSnapshot({ context_data: { workflow_steps_snapshot: [ { id: `approval_1_${roleCode}`, assignee_rule: { type: "role", value: roleCode }, }, ], }, })).toThrow(/参数校验失败/); }, ); }); describe("resolveWorkflowStepDisplayName", () => { it("hides the internal role code in legacy owner fallback tasks", () => { expect(workflowUtils.resolveWorkflowStepDisplayName({ step_name: "负责人本人申请兜底:finance", assignee_role: "finance", })).toBe("财务复核"); }); it("keeps normal readable workflow names unchanged", () => { expect(workflowUtils.resolveWorkflowStepDisplayName({ step_name: "高额财务终审", assignee_role: "finance", })).toBe("高额财务终审"); }); }); describe("buildApprovalWorkflowSnapshot", () => { it.each([ ["small", "finance", null], ["large", "ceo", "finance"], ])("uses exactly one CEO step for finance-owned reimbursement %s", async (_name, level2, level3) => { const tx = buildTx({ ownerRoles: ["finance", "employee"], managerId: MANAGER_ID }); const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ tx, entityType: "reimbursement", ownerId: OWNER_ID, submitterId: MANAGER_ID, approvalRule: { level1_role: "manager", level2_role: level2, level3_role: level3 }, }); expect(steps).toEqual([{ id: "approval_1_ceo_finance_owner", name: "总经理审批", type: "approval", assignee_rule: { type: "role", value: "ceo" }, }]); expect(tx.employeeRole.findFirst).toHaveBeenCalledWith({ where: { role_code: "ceo", employee_id: { notIn: [OWNER_ID, MANAGER_ID] }, employee: { status: "active", archived_at: null }, }, select: { employee_id: true }, }); }); it.each([["finance"], ["finance", "ceo"]])("blocks finance owner roles %j without non-applicant CEO", async (...ownerRoles) => { const tx = buildTx({ ownerRoles, fallbackHasHolder: false }); await expect(workflowUtils.buildApprovalWorkflowSnapshot({ tx, entityType: "reimbursement", ownerId: OWNER_ID, submitterId: OWNER_ID, approvalRule: { level1_role: "finance" }, })).rejects.toMatchObject({ statusCode: 409, details: { workflow: "no_actionable_approver", reason: "finance_owner_ceo_unavailable" }, }); }); it("does not simplify other entities or a finance submitter's employee-owned form", async () => { for (const [entityType, ownerRoles] of [["purchase_request", ["finance"]], ["reimbursement", ["employee"]]]) { const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ tx: buildTx({ ownerRoles }), entityType, ownerId: OWNER_ID, submitterId: FINANCE_MGR_ID, approvalRule: { level1_role: "manager", level2_role: "ceo", level3_role: "finance" }, }); expect(steps).toHaveLength(3); } }); it("keeps ordinary employee approval snapshots unchanged", async () => { const tx = buildTx({ ownerRoles: ["employee"], managerId: MANAGER_ID }); const result = await workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", level2_role: "finance", owner_self_fallback_role: "finance_mgr", }, }); expect(result.map((step) => step.assignee_rule)).toEqual([ { type: "department_manager" }, { type: "role", value: "finance" }, ]); expect(tx.employeeRole.findFirst).not.toHaveBeenCalled(); }); it("uses finance_mgr as non-self fallback for CEO owner applications", async () => { const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", level2_role: "finance", owner_self_fallback_role: "finance_mgr", }, }); expect(steps.map((step) => step.assignee_rule)).toEqual([ { type: "role", value: "finance_mgr" }, { type: "role", value: "finance" }, ]); expect(steps[0].id).toContain("owner_self_fallback"); expect(steps[0].name).toBe("财务复核"); const resolved = await resolveActionableStep({ tx, steps, startIndex: 0, ownerId: OWNER_ID, blockOnUnresolvedAssignee: true, }); expect(resolved.actionable.assigneeRole).toBe("finance_mgr"); }); it.each([ ["small", { level1_role: "manager", level2_role: "finance", level3_role: null }], ["medium", { level1_role: "manager", level2_role: "finance", level3_role: null }], ["large", { level1_role: "manager", level2_role: "ceo", level3_role: "finance" }], ])("uses one finance fallback node for CEO owner %s rules", async (_name, rule) => { const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { ...rule, owner_self_fallback_role: "finance", }, }); expect(steps.map((step) => step.assignee_rule)).toEqual([ { type: "role", value: "finance" }, ]); expect(steps[0].id).toContain("owner_self_fallback"); expect(steps[0].name).toBe("财务复核"); }); it("removes CEO self node and duplicate fallback role for large CEO owner applications", async () => { const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", level2_role: "ceo", level3_role: "finance_mgr", owner_self_fallback_role: "finance_mgr", }, }); expect(steps.map((step) => step.assignee_rule)).toEqual([ { type: "role", value: "finance_mgr" }, ]); }); it("keeps missing-manager blocking behavior when no owner fallback is configured", async () => { const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); const steps = await workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", level2_role: "finance", owner_self_fallback_role: null, }, }); await expect( resolveActionableStep({ tx, steps, startIndex: 0, ownerId: OWNER_ID, blockOnUnresolvedAssignee: true, }), ).rejects.toMatchObject({ details: { workflow: "no_actionable_approver", reason: "no_manager_found", }, }); }); it("blocks fallback when no active non-self holder exists", async () => { const tx = buildTx({ ownerRoles: ["ceo"], managerId: null, fallbackHasHolder: false, }); await expect( workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", owner_self_fallback_role: "finance", }, }), ).rejects.toMatchObject({ details: { workflow: "no_actionable_approver", reason: "owner_self_fallback_role_unavailable", }, }); }); it("blocks fallback when the owner also holds the fallback role", async () => { const tx = buildTx({ ownerRoles: ["ceo", "finance"], managerId: null, }); await expect( workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", owner_self_fallback_role: "finance", }, }), ).rejects.toMatchObject({ details: { workflow: "no_actionable_approver", reason: "owner_self_fallback_self_role", }, }); }); it("blocks unsupported owner self fallback roles before creating a task", async () => { const tx = buildTx({ ownerRoles: ["ceo"], managerId: null }); await expect( workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", owner_self_fallback_role: "hr", }, }), ).rejects.toMatchObject({ details: { ownerSelfFallbackRole: "unsupported_business_approval_role", }, }); }); }); describe("resolveAssigneeFromRule", () => { it("resolves role type", async () => { const result = await workflowUtils.resolveAssigneeFromRule({ type: "role", value: "hr" }); expect(result.assigneeRole).toBe("hr"); expect(result.assigneeId).toBe(null); }); it("throws for invalid rule", async () => { await expect(workflowUtils.resolveAssigneeFromRule(null)).rejects.toThrow(/参数校验失败/); await expect(workflowUtils.resolveAssigneeFromRule({ type: "unknown" })).rejects.toThrow(/参数校验失败/); }); it.each(["admin", "system_admin"])( "rejects protected system role %s during runtime resolution", async (roleCode) => { await expect( workflowUtils.resolveAssigneeFromRule({ type: "role", value: roleCode }), ).rejects.toMatchObject({ details: { workflow: "unsupported_business_approval_role" }, }); }, ); }); describe("toMoneyString", () => { it("converts values to string correctly", () => { expect(workflowUtils.toMoneyString(null)).toBe(null); expect(workflowUtils.toMoneyString("10.0")).toBe("10.0"); expect(workflowUtils.toMoneyString(10)).toBe("10"); }); }); it("blocks submit-time workflow resolution when the direct manager is missing", async () => { const tx = { employee: { findUnique: vi.fn(async () => ({ id: OWNER_ID, direct_manager_id: null, })), }, }; await expect( resolveActionableStep({ tx, steps: buildSteps(), startIndex: 0, ownerId: OWNER_ID, blockOnUnresolvedAssignee: true, }), ).rejects.toMatchObject({ details: { workflow: "no_actionable_approver", reason: "no_manager_found", stepId: "approval_1_manager", }, }); }); it("keeps legacy skip behavior when strict submit resolution is not requested", async () => { const tx = { employee: { findUnique: vi.fn(async () => ({ id: OWNER_ID, direct_manager_id: null, })), }, }; const result = await resolveActionableStep({ tx, steps: buildSteps(), startIndex: 0, ownerId: OWNER_ID, }); expect(result.actionable).toEqual( expect.objectContaining({ step: expect.objectContaining({ id: "approval_2_finance" }), assigneeRole: "finance", }), ); expect(result.skipped).toEqual([ expect.objectContaining({ reason: "no_manager_found", }), ]); }); });