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", }), ]); }); }); describe("workflow shared error reasons", () => { function errorFrom(run) { try { run(); } catch (error) { return error; } throw new Error("expected rejection"); } it.each([undefined, { steps: [] }, { steps: "bad" }])("explains invalid steps %s", (definition) => { expect(errorFrom(() => workflowUtils.parseWorkflowSteps(definition))).toMatchObject({ code: "VALID_001", statusCode: 400, details: { workflow: "invalid_steps", reasonCode: "workflow.steps_invalid" } }); }); it.each([[null, "approval_rule_invalid", "invalid_approval_rule"], [{}, "approval_chain_empty", "empty_approval_chain"], [{ level1_role: "admin" }, "business_role_unsupported", "unsupported_business_approval_role"]])("explains approval rule %s", (rule, reason, detail) => { expect(errorFrom(() => workflowUtils.buildWorkflowStepsFromApprovalRule(rule))).toMatchObject({ code: "VALID_001", statusCode: 400, details: { workflow: detail, reasonCode: `workflow.${reason}` } }); }); it.each([[null, {}, "assignee_rule_invalid", "invalid_assignee_rule"], [{ type: "role", value: "" }, {}, "role_assignee_invalid", "invalid_role_assignee"], [{ type: "role", value: "admin" }, {}, "business_role_unsupported", "unsupported_business_approval_role"], [{ type: "specific_user", value: "bad" }, {}, "user_assignee_invalid", "invalid_specific_user_assignee"], [{ type: "department_manager" }, {}, "manager_context_invalid", "invalid_department_manager_context"], [{ type: "unsupported" }, {}, "assignee_type_unsupported", "unsupported_assignee_rule_type"]])("explains assignee rule %s", async (rule, context, reason, detail) => { await expect(workflowUtils.resolveAssigneeFromRule(rule, context)).rejects.toMatchObject({ code: "VALID_001", statusCode: 400, details: { workflow: detail, reasonCode: `workflow.${reason}` } }); }); it.each([["hr", ["ceo"], true, "fallback_role_unsupported", 400, { ownerSelfFallbackRole: "unsupported_business_approval_role" }], ["finance", ["ceo", "finance"], true, "fallback_role_is_self", 409, { workflow: "no_actionable_approver", reason: "owner_self_fallback_self_role" }], ["finance", ["ceo"], false, "fallback_holder_unavailable", 409, { workflow: "no_actionable_approver", reason: "owner_self_fallback_role_unavailable", fallbackRole: "finance" }]])("explains fallback %s with roles %s", async (role, roles, available, reason, status, details) => { const tx = buildTx({ ownerRoles: roles, fallbackHasHolder: available }); await expect(workflowUtils.buildApprovalWorkflowSnapshot({ tx, ownerId: OWNER_ID, approvalRule: { level1_role: "manager", owner_self_fallback_role: role } })).rejects.toMatchObject({ code: "VALID_001", statusCode: status, details: { reasonCode: `workflow.${reason}`, ...details } }); if (reason !== "fallback_holder_unavailable") expect(tx.employeeRole.findFirst).not.toHaveBeenCalled(); }); it.each([[null, "chain_broken"], [{ id: OWNER_ID, direct_manager_id: null }, "no_manager_found"], [{ id: OWNER_ID, direct_manager_id: OWNER_ID }, "self_loop"]])("explains blocked manager %s while preserving optional skip", async (row, reason) => { const tx = { employee: { findUnique: vi.fn().mockResolvedValue(row) } }; const options = { tx, steps: buildSteps(), startIndex: 0, ownerId: OWNER_ID }; await expect(workflowUtils.resolveActionableStep({ ...options, blockOnUnresolvedAssignee: true })).rejects.toMatchObject({ code: "VALID_001", statusCode: 409, details: { workflow: "no_actionable_approver", reason, stepId: "approval_1_manager", reasonCode: "workflow.manager_unavailable" } }); const skipped = await workflowUtils.resolveActionableStep(options); expect(skipped.actionable.assigneeRole).toBe("finance"); expect(skipped.skipped[0].reason).toBe(reason); }); it("preserves valid specific-user and historical snapshot selection", async () => { await expect(workflowUtils.resolveAssigneeFromRule({ type: "specific_user", value: MANAGER_ID })).resolves.toMatchObject({ assigneeId: MANAGER_ID, assigneeRole: null }); const steps = buildSteps(); expect(workflowUtils.getWorkflowStepsSnapshot({ context_data: { workflow_steps_snapshot: steps }, definition: { definition_json: { steps: [{ id: "different" }] } } })).toEqual(steps); }); });