const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const Fastify = require("fastify"); const multipart = require("@fastify/multipart"); const { AppError } = require("../../../src/utils/AppError"); const reimbursementRoutes = require("../../../src/routes/reimbursements"); function buildMultipartRequest({ files }) { const boundary = "----codex-reimbursement-attachment-boundary"; const segments = []; for (const file of files) { segments.push( Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, ), Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content, "utf8"), Buffer.from("\r\n"), ); } segments.push(Buffer.from(`--${boundary}--\r\n`)); return { payload: Buffer.concat(segments), headers: { "content-type": `multipart/form-data; boundary=${boundary}`, }, }; } async function buildApp({ reimbursementStatus = "draft" } = {}) { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-")); const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const createdAttachments = []; const prisma = { reimbursementForm: { findUnique: vi.fn().mockResolvedValue({ id: reimbursementId, owner_id: "actor-1", submitter_id: "actor-1", status: reimbursementStatus, }), }, reimbursementAttachment: { create: vi.fn().mockImplementation(async ({ data, select }) => { const created = { id: `bbbbbbbb-bbbb-4bbb-8bbb-${String(createdAttachments.length + 1).padStart(12, "0")}`, reimbursement_id: data.reimbursement_id, original_name: data.original_name, mime_type: data.mime_type, file_size: data.file_size, file_sha256: data.file_sha256, storage_path: data.storage_path, created_at: new Date("2026-05-08T08:00:00.000Z"), uploader: { id: "actor-1", employee_no: "E001", name: "测试用户", }, }; createdAttachments.push(created); if (!select) { return created; } return created; }), deleteMany: vi.fn().mockImplementation(async ({ where }) => { const index = createdAttachments.findIndex( (item) => item.id === where.id && item.reimbursement_id === where.reimbursement_id, ); if (index < 0) { return { count: 0 }; } createdAttachments.splice(index, 1); return { count: 1 }; }), findFirst: vi.fn().mockImplementation(async ({ where }) => { const item = createdAttachments.find( (candidate) => candidate.id === where.id && candidate.reimbursement_id === where.reimbursement_id, ); if (!item) { return null; } return { id: item.id, storage_path: item.storage_path, original_name: item.original_name, mime_type: item.mime_type, reimbursement_form: { owner_id: "actor-1", submitter_id: "actor-1", }, }; }), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), }, $queryRaw: vi.fn().mockResolvedValue([ { id: reimbursementId, owner_id: "actor-1", submitter_id: "actor-1", status: reimbursementStatus, }, ]), $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", storageDir: tempDir, maxUploadSizeBytes: 5 * 1024 * 1024, }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: "actor-1", roleCodes: ["employee"], permissionCodes: ["expense.edit_own", "expense.view_own"], }; }); app.decorate("hasPermission", (employee, permissionCode) => { return (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.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(multipart, { attachFieldsToBody: false, limits: { files: 5, fileSize: 5 * 1024 * 1024 }, }); await app.register(reimbursementRoutes, { prefix: "/api" }); return { app, prisma, tempDir, reimbursementId, createdAttachments }; } describe("reimbursement attachment routes", () => { it("uploads multiple attachment materials without invoice OCR handling", async () => { const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp(); try { const requestPayload = buildMultipartRequest({ files: [ { filename: "contract.pdf", contentType: "application/pdf", content: "%PDF contract", }, { filename: "payment.png", contentType: "image/png", content: "png-content", }, ], }); 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(200); expect(response.json().data.items).toHaveLength(2); expect(response.json().data.items[0]).toEqual( expect.objectContaining({ originalName: "contract.pdf", mimeType: "application/pdf", uploadedBy: expect.objectContaining({ name: "测试用户" }), }), ); expect(prisma.reimbursementAttachment.create).toHaveBeenCalledTimes(2); expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "REIMBURSEMENT_ATTACHMENTS_UPLOADED", target_id: reimbursementId, }), }), ); for (const attachment of createdAttachments) { const storedBuffer = await fs.readFile(path.join(tempDir, attachment.storage_path)); expect(storedBuffer.length).toBeGreaterThan(0); } } 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" }); try { const requestPayload = buildMultipartRequest({ files: [ { filename: "contract.pdf", contentType: "application/pdf", content: "%PDF contract", }, ], }); 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(409); expect(response.json().error.details).toEqual({ status: "invalid_attachment_edit_status" }); expect(prisma.reimbursementAttachment.create).not.toHaveBeenCalled(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("downloads and deletes draft attachment materials", async () => { const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp(); try { const requestPayload = buildMultipartRequest({ files: [ { filename: "payment.png", contentType: "image/png", content: "png-content", }, ], }); const uploadResponse = await app.inject({ method: "POST", url: `/api/reimbursements/${reimbursementId}/attachments`, headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(uploadResponse.statusCode).toBe(200); const attachmentId = uploadResponse.json().data.items[0].id; const downloadResponse = await app.inject({ method: "GET", url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`, headers: { authorization: "Bearer test" }, }); expect(downloadResponse.statusCode).toBe(200); expect(downloadResponse.headers["content-type"]).toContain("image/png"); expect(downloadResponse.body).toBe("png-content"); const deleteResponse = await app.inject({ method: "DELETE", url: `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}`, headers: { authorization: "Bearer test" }, }); expect(deleteResponse.statusCode).toBe(200); expect(deleteResponse.json().data).toEqual({ attachmentId, outcome: "deleted", }); expect(prisma.reimbursementAttachment.deleteMany).toHaveBeenCalledWith({ where: { id: attachmentId, reimbursement_id: reimbursementId, }, }); expect(createdAttachments).toHaveLength(0); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); });