187 行
6.2 KiB
JavaScript
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();
|
|
});
|
|
});
|