const Fastify = require("fastify"); const workflowRoutes = require("../../../src/routes/workflow"); const { AppError } = require("../../../src/utils/AppError"); const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const OWNER_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; function buildTask(taskId, overrides = {}) { return { id: taskId, instance_id: taskId, step_id: "approval_1_manager", step_name: "经理审批", assignee_id: ACTOR_ID, assignee_role: null, status: "pending", outcome: null, comment: null, created_at: new Date("2026-04-08T08:00:00.000Z"), completed_at: null, completed_by: null, ...overrides, }; } function buildWorkflowInstance(taskId, entityType = "reimbursement") { return { id: taskId, entity_type: entityType, entity_id: taskId, current_step_id: "approval_1_manager", context_data: {}, workflow_definition: { definition_json: { steps: [ { id: "approval_1_manager", name: "经理审批", assignee_rule: { type: "role", value: "manager" }, }, ], }, }, }; } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, roleCodes: ["manager"], permissionCodes: ["expense.approve"], }; const state = { tasks: new Map( (options.tasks ?? [ buildTask("11111111-1111-4111-8111-111111111111"), buildTask("22222222-2222-4222-8222-222222222222", { status: "completed", outcome: "approved" }), buildTask("33333333-3333-4333-8333-333333333333"), buildTask("44444444-4444-4444-8444-444444444444"), ]).map((item) => [item.id, { ...item }]), ), instances: new Map( (options.instances ?? [ buildWorkflowInstance("11111111-1111-4111-8111-111111111111", "reimbursement"), buildWorkflowInstance("22222222-2222-4222-8222-222222222222", "reimbursement"), buildWorkflowInstance("33333333-3333-4333-8333-333333333333", "leave"), buildWorkflowInstance("44444444-4444-4444-8444-444444444444", "reimbursement"), ]).map((item) => [item.id, { ...item }]), ), history: [], }; const tx = { $queryRaw: vi.fn(async (_strings, ...values) => { const taskId = String(values[0] ?? ""); const task = state.tasks.get(taskId); return task ? [{ ...task }] : []; }), workflowInstance: { findUnique: vi.fn(async ({ where }) => { return state.instances.get(String(where.id)) ?? null; }), update: vi.fn(async ({ where, data }) => { const current = state.instances.get(String(where.id)); if (!current) return null; state.instances.set(String(where.id), { ...current, ...data }); return state.instances.get(String(where.id)); }), }, workflowTask: { update: vi.fn(async ({ where, data }) => { if (options.updateErrorTaskId && where.id === options.updateErrorTaskId) { throw new Error("update_failed"); } const current = state.tasks.get(where.id); if (!current) return null; state.tasks.set(where.id, { ...current, ...data }); return state.tasks.get(where.id); }), updateMany: vi.fn(async () => ({ count: 0 })), create: vi.fn(async () => { throw new Error("unexpected_create"); }), }, workflowHistory: { create: vi.fn(async ({ data }) => { state.history.push(data); return { id: `history-${state.history.length}` }; }), count: vi.fn(async () => 0), }, reimbursementForm: { update: vi.fn(async () => ({})), findUnique: vi.fn(async () => ({ id: "reimbursement-1", form_number: "BX-TEST", total_amount: "100.00", owner_id: options.reimbursementOwnerId ?? OWNER_ID, submitter_id: options.reimbursementSubmitterId ?? options.reimbursementOwnerId ?? OWNER_ID, owner: { id: options.reimbursementOwnerId ?? OWNER_ID, name: "申请人" }, })), }, leaveRequest: { findUnique: vi.fn(async () => null), update: vi.fn(async () => ({})), }, leaveLedger: { findFirst: vi.fn(async () => null), }, purchaseRequest: { update: vi.fn(async () => ({})), }, travelRequest: { update: vi.fn(async () => ({})), }, notificationChannel: { findMany: vi.fn(async () => []), }, employee: { findMany: vi.fn(async () => []), findUnique: vi.fn(async () => ({ id: "target", status: "active", roles: [{ role_code: "manager" }], })), }, notificationDelivery: { create: vi.fn(async () => ({ id: "delivery-1" })), update: vi.fn(async () => ({})), }, notification: { create: vi.fn(async ({ data }) => { if (options.notificationCreateError) { throw options.notificationCreateError; } return { id: "notification-1", ...data }; }), }, }; const prisma = { $transaction: vi.fn(async (callback) => callback(tx)), }; app.decorate("prisma", prisma); app.decorate("config", { imNotifierEnabled: false, emailEnabled: false, }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; const permissionCodes = actor.permissionCodes ?? []; if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("assertEntityPermission", () => {}); app.setErrorHandler((error, request, reply) => { reply.status(error.statusCode || 500).send({ requestId: request.id, error: { code: error.code || "SYS_001", message: error.message, details: error.details ?? null, }, }); }); await app.register(workflowRoutes, { prefix: "/api" }); return { app, prisma, state, tx }; } describe("workflow batch routes", () => { it.each([ ["POST", "/workflow/tasks/invalid/approve", {}, "workflow.task_id_invalid"], ["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/reject", { comment: " " }, "workflow.comment_invalid"], ["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/transfer", {}, "workflow.transfer_input_invalid"], ["POST", "/workflow/batch-approve", { taskIds: [] }, "workflow.batch_approve_input_invalid"], ["POST", "/workflow/batch-reject", { taskIds: [] }, "workflow.batch_reject_input_invalid"], ["POST", "/workflow/tasks/11111111-1111-4111-8111-111111111111/remind", { comment: "长".repeat(201) }, "workflow.remind_input_invalid"], ["GET", "/workflow/tasks/summary?scope=invalid", undefined, "workflow.summary_query_invalid"], ["GET", "/workflow/tasks?page=0", undefined, "workflow.task_list_query_invalid"], ["GET", "/workflow/approvers?page=0", undefined, "workflow.approver_query_invalid"], ])("rejects malformed %s %s before starting a business transaction", async (method, path, payload, reasonCode) => { const { app, prisma, state } = await buildApp({ actor: { id: ACTOR_ID, roleCodes: ["manager"], permissionCodes: ["expense.approve", "expense.transfer"] }, }); const before = JSON.stringify([...state.tasks]); try { const response = await app.inject({ method, url: `/api${path}`, ...(payload === undefined ? {} : { payload }) }); expect(response.statusCode).toBe(400); expect(response.json().error.details.reasonCode).toBe(reasonCode); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(JSON.stringify([...state.tasks])).toBe(before); } finally { await app.close(); } }); it("rejects self approval on a single task action", async () => { const { app, state } = await buildApp({ reimbursementOwnerId: ACTOR_ID, }); const response = await app.inject({ method: "POST", url: "/api/workflow/tasks/11111111-1111-4111-8111-111111111111/approve", }); expect(response.statusCode).toBe(409); expect(response.json().error).toEqual( expect.objectContaining({ code: "VALID_001", details: expect.objectContaining({ workflow: "self_approval_not_allowed", reasonCode: "workflow.self_approval_forbidden", }), }), ); expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("pending"); await app.close(); }); it("approves reimbursement tasks and skips already processed ones", async () => { const { app, prisma, state } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/workflow/batch-approve", payload: { taskIds: [ "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222", ], }, }); expect(response.statusCode).toBe(200); expect(prisma.$transaction).toHaveBeenCalledTimes(2); expect(response.json().data).toEqual({ results: [ { taskId: "11111111-1111-4111-8111-111111111111", outcome: "approved", }, { taskId: "22222222-2222-4222-8222-222222222222", outcome: "skipped", reason: "already_processed", }, ], summary: { approved: 1, rejected: 0, skipped: 1, errored: 0, }, }); expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("completed"); await app.close(); }); it("rejects reimbursement tasks with required shared comment", async () => { const { app, state } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/workflow/batch-reject", payload: { taskIds: ["44444444-4444-4444-8444-444444444444"], comment: "材料不完整,请补充说明后重新提交。", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ results: [ { taskId: "44444444-4444-4444-8444-444444444444", outcome: "rejected", }, ], summary: { approved: 0, rejected: 1, skipped: 0, errored: 0, }, }); expect(state.tasks.get("44444444-4444-4444-8444-444444444444").status).toBe("rejected"); const invalidResponse = await app.inject({ method: "POST", url: "/api/workflow/batch-reject", payload: { taskIds: ["44444444-4444-4444-8444-444444444444"], comment: " ", }, }); expect(invalidResponse.statusCode).toBe(400); await app.close(); }); it("keeps a return committed when outcome notification creation fails", async () => { const { app, state, tx } = await buildApp({ notificationCreateError: new Error("synthetic notification failure"), }); const taskId = "11111111-1111-4111-8111-111111111111"; const response = await app.inject({ method: "POST", url: `/api/workflow/tasks/${taskId}/return`, payload: { comment: "材料不完整,请修改后重新提交。", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ taskId, outcome: "returned", reimbursementStatus: "draft", }), ); expect(tx.notification.create).toHaveBeenCalledTimes(1); expect(state.tasks.get(taskId)).toEqual( expect.objectContaining({ status: "completed", outcome: "returned", }), ); expect(tx.reimbursementForm.update).toHaveBeenCalledWith({ where: { id: taskId }, data: expect.objectContaining({ status: "draft" }), }); expect(state.history).toEqual([ expect.objectContaining({ instance_id: taskId, task_id: taskId, action: "return", }), ]); await app.close(); }); it("does not roll back successful tasks when one task fails or has unsupported entity type", async () => { const { app, state } = await buildApp({ updateErrorTaskId: "44444444-4444-4444-8444-444444444444", }); const response = await app.inject({ method: "POST", url: "/api/workflow/batch-approve", payload: { taskIds: [ "11111111-1111-4111-8111-111111111111", "33333333-3333-4333-8333-333333333333", "44444444-4444-4444-8444-444444444444", ], }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ results: [ { taskId: "11111111-1111-4111-8111-111111111111", outcome: "approved", }, { taskId: "33333333-3333-4333-8333-333333333333", outcome: "error", reason: "VALID_001: 参数校验失败", }, { taskId: "44444444-4444-4444-8444-444444444444", outcome: "error", reason: "update_failed", }, ], summary: { approved: 1, rejected: 0, skipped: 0, errored: 2, }, }); expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("completed"); expect(state.tasks.get("44444444-4444-4444-8444-444444444444").status).toBe("pending"); await app.close(); }); it("rejects self approval in batch actions without changing the task", async () => { const { app, state } = await buildApp({ reimbursementOwnerId: ACTOR_ID, }); const response = await app.inject({ method: "POST", url: "/api/workflow/batch-approve", payload: { taskIds: ["11111111-1111-4111-8111-111111111111"], }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ results: [ { taskId: "11111111-1111-4111-8111-111111111111", outcome: "error", reason: "self_approval_not_allowed", }, ], summary: { approved: 0, rejected: 0, skipped: 0, errored: 1, }, }); expect(state.tasks.get("11111111-1111-4111-8111-111111111111").status).toBe("pending"); await app.close(); }); it("rejects oversized batch payloads and missing permissions", async () => { const { app } = await buildApp(); const oversizedPayload = { taskIds: Array.from({ length: 51 }, (_, index) => `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, ), }; const invalidResponse = await app.inject({ method: "POST", url: "/api/workflow/batch-approve", payload: oversizedPayload, }); expect(invalidResponse.statusCode).toBe(400); await app.close(); const noPermission = await buildApp({ actor: { id: ACTOR_ID, roleCodes: ["employee"], permissionCodes: ["expense.view_own"], }, }); const forbiddenResponse = await noPermission.app.inject({ method: "POST", url: "/api/workflow/batch-approve", payload: { taskIds: ["11111111-1111-4111-8111-111111111111"], }, }); expect(forbiddenResponse.statusCode).toBe(403); await noPermission.app.close(); }); });