文件
hyb-oa/server/tests/unit/routes/workflow.batch.test.js
T

470 行
13 KiB
JavaScript

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