修复报销附件上传与中文文件名(07-28-travel-reimbursement-guide)

这个提交包含在:
2026-07-30 14:41:08 +08:00
父节点 531d2846a6
当前提交 734153847b
共修改 15 个文件,包含 434 行新增和 65 行删除
@@ -180,7 +180,7 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
await app.register(multipart, {
attachFieldsToBody: false,
limits: { files: 5, fileSize: 5 * 1024 * 1024 },
limits: { files: 1, fileSize: 5 * 1024 * 1024 },
});
await app.register(reimbursementRoutes, { prefix: "/api" });
@@ -188,7 +188,7 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
}
describe("reimbursement attachment routes", () => {
it("uploads multiple attachment materials without invoice OCR handling", async () => {
it("uploads five attachment materials with the production global one-file baseline", async () => {
const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp();
try {
@@ -204,6 +204,21 @@ describe("reimbursement attachment routes", () => {
contentType: "image/png",
content: "png-content",
},
{
filename: "order.jpg",
contentType: "image/jpeg",
content: "jpg-content",
},
{
filename: "note.webp",
contentType: "image/webp",
content: "webp-content",
},
{
filename: "extra.pdf",
contentType: "application/pdf",
content: "%PDF extra",
},
],
});
@@ -218,7 +233,7 @@ describe("reimbursement attachment routes", () => {
});
expect(response.statusCode).toBe(200);
expect(response.json().data.items).toHaveLength(2);
expect(response.json().data.items).toHaveLength(5);
expect(response.json().data.items[0]).toEqual(
expect.objectContaining({
originalName: "contract.pdf",
@@ -230,7 +245,7 @@ describe("reimbursement attachment routes", () => {
uploadedBy: expect.objectContaining({ name: "测试用户" }),
}),
);
expect(prisma.reimbursementAttachment.create).toHaveBeenCalledTimes(2);
expect(prisma.reimbursementAttachment.create).toHaveBeenCalledTimes(5);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
@@ -254,6 +269,41 @@ describe("reimbursement attachment routes", () => {
}
});
it("returns a stable validation error when a request contains six files", async () => {
const { app, prisma, tempDir, reimbursementId } = await buildApp();
try {
const requestPayload = buildMultipartRequest({
files: Array.from({ length: 6 }, (_, index) => ({
filename: `attachment-${index + 1}.pdf`,
contentType: "application/pdf",
content: `%PDF attachment ${index + 1}`,
})),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${reimbursementId}/attachments`,
headers: {
authorization: "Bearer test",
...requestPayload.headers,
},
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toMatchObject({
code: "VALID_001",
details: { file: "too_many_files", maxFiles: 5 },
});
expect(prisma.reimbursementAttachment.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects attachment upload after reimbursement leaves editable status", async () => {
const { app, prisma, tempDir, reimbursementId } = await buildApp({ reimbursementStatus: "reviewing" });
@@ -322,6 +372,9 @@ describe("reimbursement attachment routes", () => {
expect(downloadResponse.statusCode).toBe(200);
expect(downloadResponse.headers["content-type"]).toContain("image/png");
expect(downloadResponse.headers["content-disposition"]).toBe(
"inline; filename=\"payment.png\"; filename*=UTF-8''payment.png",
);
expect(downloadResponse.body).toBe("png-content");
const deleteResponse = await app.inject({
@@ -358,6 +411,39 @@ describe("reimbursement attachment routes", () => {
}
});
it("returns Chinese attachment names through a safe inline Content-Disposition", async () => {
const { app, tempDir, reimbursementId, createdAttachments } = await buildApp();
try {
const requestPayload = buildMultipartRequest({
files: [{ filename: "payment.pdf", contentType: "application/pdf", content: "%PDF payment" }],
});
const uploadResponse = await app.inject({
method: "POST",
url: `/api/reimbursements/${reimbursementId}/attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
const attachmentId = uploadResponse.json().data.items[0].id;
createdAttachments[0].original_name = "支付截图.pdf";
const response = await app.inject({
method: "GET",
url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-disposition"]).toBe(
"inline; filename=\"attachment.pdf\"; filename*=UTF-8''%E6%94%AF%E4%BB%98%E6%88%AA%E5%9B%BE.pdf",
);
expect(response.headers["content-disposition"]).not.toContain("支付截图");
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects attachment deletion without a meaningful reason before mutation", async () => {
const { app, prisma, tempDir, reimbursementId } = await buildApp();