完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
@@ -0,0 +1,186 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户