文件
hyb-oa/server/tests/unit/routes/reimbursements.payment-batch-gate.test.js
T

187 行
6.2 KiB
JavaScript

const Fastify = require("fastify");
const { AppError } = require("../../../src/utils/AppError");
const reimbursementRoutes = require("../../../src/routes/reimbursements");
const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111";
const ACTOR_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333";
async function buildApp(options = {}) {
const rolloutType = options.rolloutType ?? "global";
const departmentIds = options.departmentIds ?? [];
const activePaymentBatch = options.activePaymentBatch ?? false;
const tx = {
$queryRaw: vi.fn(async () => [{
id: REIMBURSEMENT_ID,
status: "approved",
form_number: "BX-20260718-001",
owner_id: ACTOR_ID,
submitter_id: ACTOR_ID,
department_id: DEPARTMENT_ID,
payment_voucher_no: null,
payment_date: null,
payment_channel: null,
paid_at: null,
paid_by: null,
}]),
featureFlag: {
findUnique: vi.fn(async ({ where }) => {
if (where.flag_key !== "payment_batch_v1") return null;
return {
id: "flag-payment-batch",
flag_key: "payment_batch_v1",
override_mode: "force_on",
rollout_type: rolloutType,
department_ids: departmentIds,
note: "",
version: 1,
created_at: null,
created_by: null,
updated_at: null,
updated_by: null,
};
}),
},
paymentBatchItem: {
findFirst: vi.fn(async () => activePaymentBatch ? { id: "active-batch-item" } : null),
},
reimbursementForm: { update: vi.fn() },
invoicePool: { updateMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const prisma = {
...tx,
$transaction: vi.fn(async (callback) => callback(tx)),
};
const app = Fastify();
app.decorate("prisma", prisma);
app.decorate("config", {
paymentBatchEnabled: true,
cutoverConsoleEnabled: false,
appTimezone: "Asia/Shanghai",
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
legal_entity_id: null,
roleCodes: ["finance"],
permissionCodes: ["expense.finance.pay"],
};
});
app.decorate("hasPermission", (employee, permissionCode) =>
(employee?.permissionCodes ?? []).includes("*") ||
(employee?.permissionCodes ?? []).includes(permissionCode),
);
app.decorate("requirePermission", (permissionCode) => async (request) => {
await app.authenticate(request);
if (!app.hasPermission(request.employee, permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("assertEntityPermission", () => {});
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
requestId: request.id,
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(500).send({
requestId: request.id,
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
});
});
await app.register(reimbursementRoutes, { prefix: "/api" });
return { app, prisma, tx };
}
describe("reimbursement direct payment batch gate", () => {
it("returns 409 with zero payment writes when payment_batch_v1 is enabled", async () => {
const { app, prisma, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/pay`,
headers: { authorization: "Bearer test" },
payload: {
paymentVoucherNo: "SYNTHETIC-001",
paymentDate: "2026-07-18",
paymentChannel: "bank",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.payment).toBe("payment_batch_v1_required");
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("requires the batch path when the source department is in the rollout allowlist", async () => {
const { app, tx } = await buildApp({
rolloutType: "department_allowlist",
departmentIds: [DEPARTMENT_ID],
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/pay`,
headers: { authorization: "Bearer test" },
payload: {
paymentVoucherNo: "SYNTHETIC-ALLOWLIST-001",
paymentDate: "2026-07-18",
paymentChannel: "bank",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.payment).toBe("payment_batch_v1_required");
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("keeps direct payment closed for an active batch after the source department leaves rollout", async () => {
const { app, tx } = await buildApp({
rolloutType: "department_allowlist",
departmentIds: ["44444444-4444-4444-8444-444444444444"],
activePaymentBatch: true,
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/pay`,
headers: { authorization: "Bearer test" },
payload: {
paymentVoucherNo: "SYNTHETIC-ACTIVE-BATCH-001",
paymentDate: "2026-07-18",
paymentChannel: "bank",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toMatchObject({
payment: "payment_batch_v1_required",
paymentBatch: "active_batch_exists",
});
expect(tx.paymentBatchItem.findFirst).toHaveBeenCalledTimes(1);
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
});