From 709f743679009e53a761a051e14d1b4b19ae2f6b Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 8 May 2026 10:05:46 +0800 Subject: [PATCH] =?UTF-8?q?R111/A11=20=E8=A1=A5=E9=BD=90=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E5=92=8C=E8=A7=86=E8=A7=89=E9=AA=8C?= =?UTF-8?q?=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../r111-invoice-reimbursement-guard.spec.ts | 528 ++++++++++++++++++ .../routes/reimbursements.attachments.test.js | 320 +++++++++++ 2 files changed, 848 insertions(+) create mode 100644 client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts create mode 100644 server/tests/unit/routes/reimbursements.attachments.test.js diff --git a/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts new file mode 100644 index 00000000..6b7d9911 --- /dev/null +++ b/client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts @@ -0,0 +1,528 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { expect, test, type Browser, type Page, type Route } from "@playwright/test"; + +const ROOT_DIR = path.resolve(process.cwd(), ".."); +const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r111-invoice-reimbursement"); +const WECOM_UA = + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN"; + +const reimbursementId = "11111111-1111-4111-8111-111111111111"; +const ownerId = "employee-r111"; +const pendingInvoiceId = "22222222-2222-4222-8222-222222222222"; +const attachmentId = "33333333-3333-4333-8333-333333333333"; + +function envelope(data: unknown) { + return { requestId: "mock-r111-invoice-reimbursement", data }; +} + +async function fulfillJson(route: Route, data: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(envelope(data)), + }); +} + +function screenshotPath(fileName: string) { + mkdirSync(SCREENSHOT_DIR, { recursive: true }); + return path.join(SCREENSHOT_DIR, fileName.endsWith(".png") ? fileName : `${fileName}.png`); +} + +function buildMe() { + return { + id: ownerId, + employeeNo: "E111", + name: "测试员工", + phone: "13800000000", + email: "employee@hyinda.com", + departmentId: "dept-r111", + directManagerId: null, + status: "active", + notificationPushEnabled: true, + roleCodes: ["employee"], + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 60, + }, + wecomBinding: { corpId: "ww-test", userId: "r111-user", boundAt: "2026-05-08T08:00:00.000Z" }, + }; +} + +function buildExpenseTypes() { + return { + items: [ + { + id: "expense-type-r111", + code: "business_client_meal", + name: "业务招待", + category: "expense", + enabled: true, + }, + ], + }; +} + +function buildInvoice() { + return { + id: pendingInvoiceId, + status: "bound", + invoiceCode: "044002600111", + invoiceNumber: "12345678", + invoiceDate: "2026-05-08", + sellerName: "北京悦海堂餐饮管理有限公司", + totalAmount: "1683.00", + originalName: "北京悦海堂发票.pdf", + linkedItemId: "item-r111", + }; +} + +function buildAttachment() { + return { + id: attachmentId, + originalName: "付款截图.png", + mimeType: "image/png", + fileSize: 2048, + fileSha256: "sha256-attachment-r111", + uploadedBy: { id: ownerId, name: "测试员工", employeeNo: "E111" }, + createdAt: "2026-05-08T08:10:00.000Z", + }; +} + +function buildReimbursementState() { + return { + invoices: [] as ReturnType[], + attachments: [] as ReturnType[], + statusPollCount: 0, + }; +} + +function buildReimbursementDetail(state: ReturnType) { + return { + id: reimbursementId, + formNumber: "BX20260508001", + submitterId: ownerId, + ownerId, + departmentId: "dept-r111", + department: { id: "dept-r111", name: "研发部" }, + status: "draft", + createdAt: "2026-05-08T08:00:00.000Z", + submittedAt: null, + reimbursementDate: "2026-05-08", + totalAmount: "1683.00", + description: "客户招待报销", + projectId: null, + linkedTravelRequestId: null, + linkedTravelRequest: null, + project: null, + owner: { id: ownerId, employee_no: "E111", name: "测试员工" }, + submitter: { id: ownerId, employee_no: "E111", name: "测试员工" }, + invoices: state.invoices, + attachments: state.attachments, + budgetHint: null, + pendingWorkflow: null, + workflowProgress: null, + approvalHistory: [], + riskFlags: [], + items: [ + { + id: "item-r111", + expenseTypeId: "expense-type-r111", + expenseDate: "2026-05-08", + amount: "1683.00", + description: "客户招待", + expenseType: { + id: "expense-type-r111", + code: "business_client_meal", + name: "业务招待", + category: "expense", + enabled: true, + }, + }, + ], + }; +} + +function navigationItem(key: string, href: string, label: string, icon: string) { + return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null }; +} + +async function mockApis(page: Page, state: ReturnType) { + await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pathName = url.pathname; + + if (pathName === "/api/auth/sso/providers") { + await fulfillJson(route, []); + return; + } + + if (pathName === "/api/auth/wecom/config") { + await fulfillJson(route, { + enabled: true, + entryEnabled: true, + oauthEnabled: true, + corpId: "ww-test", + agentId: "1000002", + loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile", + bindEnabled: true, + defaultReturnTo: "/mobile", + status: "ready", + statusReasons: [], + }); + return; + } + + if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" }, + body: JSON.stringify(envelope({ accessToken: "mock-r111-token", expiresIn: 900, mustChangePassword: false })), + }); + return; + } + + if (pathName === "/api/me") { + await fulfillJson(route, buildMe()); + return; + } + + if (pathName === "/api/system/navigation") { + await fulfillJson(route, { + workspace: { + id: "workspace-r111", + persisted: true, + profile: "small_team_internal", + orgMode: "single_company", + primaryEntryMode: "wecom", + notificationStrategy: "in_app", + devicePriority: "mobile", + moduleVisibility: {}, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, + sections: { + main: [ + navigationItem("portal", "/portal", "工作台", "home_app_logo"), + navigationItem("expense", "/reimbursements", "报销", "request_quote"), + navigationItem("invoice", "/invoice-pool", "票据", "receipt_long"), + ], + admin: [], + backup: [], + }, + recommendedRolePresets: [], + }); + return; + } + + if (pathName === "/api/notifications") { + await fulfillJson(route, { page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] }); + return; + } + + if (pathName === "/api/portal/home") { + await fulfillJson(route, { + workspace: { id: "workspace-r111", persisted: true, profile: "small_team_internal" }, + greeting: { name: "测试员工" }, + cards: [], + pendingTasks: { count: 0, href: "/workflow/tasks" }, + myApplications: [], + riskSummary: null, + announcements: [], + quickLinks: [{ key: "expense", title: "新建报销", href: "/reimbursements" }], + todayPresence: null, + teamPresence: null, + weeklyReport: null, + weeklySummary: null, + todoSummary: null, + projectSummary: null, + peopleLifecycle: null, + adoptionSummary: null, + recentActivities: [], + teamStatusBoard: [], + }); + return; + } + + if (pathName === "/api/expense-types") { + await fulfillJson(route, buildExpenseTypes()); + return; + } + + if (pathName === "/api/projects") { + await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }); + return; + } + + if (pathName === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") { + await fulfillJson(route, buildReimbursementDetail(state)); + return; + } + + if (pathName === `/api/reimbursements/${reimbursementId}/precheck`) { + await fulfillJson(route, { + decision: "pass", + standardChecks: [], + budget: null, + riskFlags: [], + }); + return; + } + + if (pathName === `/api/reimbursements/${reimbursementId}/attachments` && request.method() === "POST") { + state.attachments = [buildAttachment()]; + await fulfillJson(route, { items: state.attachments }); + return; + } + + if (pathName === "/api/invoice-pool/bindable") { + await fulfillJson(route, { + items: [ + { + id: "44444444-4444-4444-8444-444444444444", + invoiceCode: "044002600111", + invoiceNumber: "87654321", + invoiceDate: "2026-05-08", + sellerName: "北京悦海堂餐饮管理有限公司", + totalAmount: "1683.00", + originalName: "已有发票.pdf", + createdAt: "2026-05-08T08:00:00.000Z", + }, + ], + }); + return; + } + + if (pathName === "/api/invoice-pool/upload") { + state.statusPollCount = 0; + await fulfillJson(route, { + invoicePoolId: pendingInvoiceId, + status: "pending_ocr", + fileSha256: "sha256-r111", + uploadAction: "create", + unavailableReason: null, + unavailableMessage: null, + }); + return; + } + + if (pathName === "/api/invoice-pool/status") { + state.statusPollCount += 1; + await fulfillJson(route, { + items: [ + { + id: pendingInvoiceId, + status: "available", + reason: "available", + message: "发票可添加", + invoiceCode: "044002600111", + invoiceNumber: "12345678", + invoiceDate: "2026-05-08", + sellerName: "北京悦海堂餐饮管理有限公司", + totalAmount: "1683.00", + originalName: "北京悦海堂发票.pdf", + linkedReimbursementId: null, + linkedItemId: null, + linkedTravelOrderId: null, + ocrAttempts: 1, + ocrLastError: null, + ocrCompletedAt: "2026-05-08T08:05:00.000Z", + updatedAt: "2026-05-08T08:05:00.000Z", + }, + ], + missingIds: [], + }); + return; + } + + if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) { + state.invoices = [buildInvoice()]; + await fulfillJson(route, { + id: pendingInvoiceId, + status: "bound", + linkedReimbursementId: reimbursementId, + linkedItemId: "item-r111", + linkedTravelOrderId: null, + totalAmount: "1683.00", + }); + return; + } + + if (pathName === `/api/invoice-pool/${pendingInvoiceId}/unbind`) { + state.invoices = []; + await fulfillJson(route, { + id: pendingInvoiceId, + status: "available", + linkedReimbursementId: null, + linkedItemId: null, + linkedTravelOrderId: null, + totalAmount: "1683.00", + }); + return; + } + + await fulfillJson(route, {}); + }); +} + +async function login(page: Page) { + await page.goto("/login"); + const accountLogin = page.getByRole("button", { name: "使用账号密码登录" }); + if (await accountLogin.isVisible().catch(() => false)) { + await accountLogin.click(); + } + await page.getByTestId("login-identifier").fill("employee@hyinda.com"); + await page.getByTestId("login-password").fill("EmployeePassword1234"); + await page.getByTestId("login-submit").click(); + await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 }); +} + +async function newWecomPage(browser: Browser) { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + userAgent: WECOM_UA, + locale: "zh-CN", + serviceWorkers: "block", + }); + return { context, page: await context.newPage() }; +} + +async function collectVisualMetrics(page: Page) { + return page.evaluate(() => { + function parseRgb(value: string) { + const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)/i); + if (!match) return null; + return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a: match[4] == null ? 1 : Number(match[4]) }; + } + + const nav = document.querySelector("[data-testid='mobile-nav-reimbursements']")?.closest("nav"); + const navRect = nav?.getBoundingClientRect(); + const activeNav = + document.querySelector("[data-testid='mobile-nav-reimbursements']") ?? + document.querySelector("nav a[aria-current='page']"); + const activeStyle = activeNav ? getComputedStyle(activeNav) : null; + const activeRgb = activeStyle ? parseRgb(activeStyle.backgroundColor) : null; + const bodyText = document.body?.innerText ?? ""; + + return { + width: window.innerWidth, + scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0), + tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length, + navHeight: navRect ? Math.round(navRect.height) : 0, + activeNavBackground: activeStyle?.backgroundColor ?? "", + activeNavAverage: activeRgb ? Math.round((activeRgb.r + activeRgb.g + activeRgb.b) / 3) : 255, + activeNavAlpha: activeRgb?.a ?? 0, + activeElementTestId: document.activeElement?.getAttribute("data-testid") ?? "", + bodyText, + }; + }); +} + +test.describe("R111 国内报销发票与附件双端体验", () => { + test("PC 报销详情上传发票后自动添加,并提供附件材料", async ({ page }) => { + const state = buildReimbursementState(); + await mockApis(page, state); + await login(page); + + await page.setViewportSize({ width: 1440, height: 960 }); + await page.goto(`/reimbursements/${reimbursementId}`); + await expect(page.getByText("报销详情 BX20260508001")).toBeVisible(); + await expect(page.getByText("预算与审核提示")).toBeVisible(); + await expect(page.getByText("ERP")).toHaveCount(0); + + await page.getByTestId("reimbursement-invoice-add").click(); + await expect(page.getByTestId("reimbursement-invoice-dialog")).toBeVisible(); + await page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ + name: "北京悦海堂发票.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF r111 invoice"), + }); + await page.getByTestId("reimbursement-invoice-upload-submit").click(); + await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 }); + expect(state.statusPollCount).toBeGreaterThanOrEqual(1); + + await page.keyboard.press("Escape"); + await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible(); + await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible(); + + await page.locator("#reimbursement-attachment-upload").setInputFiles({ + name: "付款截图.png", + mimeType: "image/png", + buffer: Buffer.from("r111 attachment"), + }); + await page.getByRole("button", { name: "上传附件" }).click(); + await expect(page.getByText("付款截图.png")).toBeVisible(); + + const bodyText = await page.locator("body").innerText(); + expect(bodyText).not.toContain("绑定整单"); + expect(bodyText).not.toContain("不绑定明细"); + expect(bodyText).not.toContain("发票池"); + expect(bodyText).not.toContain("OCR"); + await page.screenshot({ path: screenshotPath("pc-reimbursement-detail.png"), fullPage: true }); + }); + + test("企业微信手机端保持移动布局,发票和附件能力与 PC 一致", async ({ browser }) => { + const state = buildReimbursementState(); + const mobile = await newWecomPage(browser); + await mockApis(mobile.page, state); + + try { + await login(mobile.page); + await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`); + await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible(); + await expect(mobile.page.getByRole("heading", { name: "附件材料" })).toBeVisible(); + + const beforeDialogMetrics = await collectVisualMetrics(mobile.page); + expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2); + expect(beforeDialogMetrics.tableCount).toBe(0); + expect(beforeDialogMetrics.navHeight).toBeLessThanOrEqual(68); + if (beforeDialogMetrics.activeNavAlpha > 0) { + expect(beforeDialogMetrics.activeNavAlpha).toBeGreaterThan(0.9); + expect(beforeDialogMetrics.activeNavAverage, beforeDialogMetrics.activeNavBackground).toBeLessThan(245); + } + + await mobile.page.getByTestId("reimbursement-invoice-add").click(); + await expect(mobile.page.getByTestId("reimbursement-invoice-dialog")).toBeVisible(); + const dialogMetrics = await collectVisualMetrics(mobile.page); + expect(dialogMetrics.activeElementTestId).not.toBe("mobile-reimbursement-invoice-search"); + + await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({ + name: "北京悦海堂发票.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF r111 mobile invoice"), + }); + await mobile.page.getByTestId("reimbursement-invoice-upload-submit").click(); + await expect( + mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"), + ).toBeVisible({ timeout: 10_000 }); + await mobile.page.keyboard.press("Escape"); + await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible(); + + await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "选择" }).click(); + await mobile.page.locator("[data-testid='mobile-reimbursement-attachments'] input[type='file']").setInputFiles({ + name: "付款截图.png", + mimeType: "image/png", + buffer: Buffer.from("r111 mobile attachment"), + }); + await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "上传附件" }).click(); + await expect(mobile.page.getByText("付款截图.png")).toBeVisible(); + + const bodyText = await mobile.page.locator("body").innerText(); + expect(bodyText).not.toContain("绑定整单"); + expect(bodyText).not.toContain("不绑定明细"); + expect(bodyText).not.toContain("发票池"); + expect(bodyText).not.toContain("OCR"); + await mobile.page.screenshot({ path: screenshotPath("mobile-wecom-reimbursement-detail.png"), fullPage: true }); + } finally { + await mobile.context.close(); + } + }); +}); diff --git a/server/tests/unit/routes/reimbursements.attachments.test.js b/server/tests/unit/routes/reimbursements.attachments.test.js new file mode 100644 index 00000000..90bd2cdb --- /dev/null +++ b/server/tests/unit/routes/reimbursements.attachments.test.js @@ -0,0 +1,320 @@ +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 }); + } + }); +});