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 () => ({})), }, }; 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("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", }), }), ); 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("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(); }); });