const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const reimbursementRoutes = require("../../../src/routes/reimbursements"); const invoicePoolRoutes = require("../../../src/routes/invoicePool"); const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; const OWNER_ID = "22222222-2222-4222-8222-222222222222"; const SUBMITTER_ID = "33333333-3333-4333-8333-333333333333"; const ACTIVE_INSTANCE_ID = "44444444-4444-4444-8444-444444444444"; const INVOICE_ID = "55555555-5555-4555-8555-555555555555"; const ITEM_ID = "66666666-6666-4666-8666-666666666666"; function pickSelect(source, select) { if (!select) { return { ...source }; } return Object.fromEntries( Object.entries(select) .filter(([, enabled]) => Boolean(enabled)) .map(([key]) => [key, source[key]]), ); } function buildQueryRawHandler(state) { return vi.fn(async (strings, ...values) => { const sql = Array.isArray(strings) ? strings.join("?") : String(strings); const entityId = values[0] ? String(values[0]) : null; if (sql.includes('FROM "reimbursement_forms"')) { if (!state.reimbursement || (entityId && state.reimbursement.id !== entityId)) { return []; } return [state.reimbursement]; } if (sql.includes('FROM "invoice_pool"')) { if (!state.invoice || (entityId && state.invoice.id !== entityId)) { return []; } return [{ id: state.invoice.id }]; } return []; }); } function buildPrisma({ processedTaskCount = 0 } = {}) { const state = { reimbursement: { id: REIMBURSEMENT_ID, owner_id: OWNER_ID, submitter_id: SUBMITTER_ID, status: "reviewing", }, workflowInstance: { id: ACTIVE_INSTANCE_ID, entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID, current_status: "pending", }, invoice: { id: INVOICE_ID, owner_employee_id: OWNER_ID, uploaded_by_employee_id: OWNER_ID, status: "bound", linked_reimbursement_id: REIMBURSEMENT_ID, linked_item_id: ITEM_ID, travel_order_id: null, archived_reason: null, file_sha256: "sha256-invoice", invoice_code: "044001", invoice_number: "12345678", invoice_date: new Date("2026-05-01T00:00:00.000Z"), seller_name: "测试销售方", total_amount: "1683.00", is_duplicate_override: false, }, processedTaskCount, }; const tx = { $queryRaw: buildQueryRawHandler(state), workflowInstance: { findFirst: vi.fn(async () => { if ( state.workflowInstance && state.workflowInstance.entity_id === REIMBURSEMENT_ID && state.workflowInstance.current_status === "pending" ) { return { id: state.workflowInstance.id }; } return null; }), update: vi.fn(async ({ data }) => { state.workflowInstance = { ...state.workflowInstance, ...data }; return state.workflowInstance; }), }, workflowTask: { count: vi.fn(async () => state.processedTaskCount), updateMany: vi.fn(async () => ({ count: 1 })), }, reimbursementForm: { update: vi.fn(async ({ data }) => { state.reimbursement = { ...state.reimbursement, ...data }; return state.reimbursement; }), }, reimbursementItem: { findFirst: vi.fn(async ({ where }) => { if (where.id === ITEM_ID && where.reimbursement_id === REIMBURSEMENT_ID) { return { id: ITEM_ID }; } return null; }), }, workflowHistory: { create: vi.fn(async () => ({ id: "history-1" })), }, invoicePool: { findMany: vi.fn(async () => []), findUnique: vi.fn(async ({ where, select }) => { if (!state.invoice || where.id !== state.invoice.id) { return null; } return pickSelect(state.invoice, select); }), updateMany: vi.fn(async ({ where, data }) => { const matches = state.invoice && state.invoice.linked_reimbursement_id === where.linked_reimbursement_id && state.invoice.status === where.status; if (!matches) { return { count: 0 }; } state.invoice = { ...state.invoice, ...data }; return { count: 1 }; }), update: vi.fn(async ({ where, data, select }) => { if (!state.invoice || where.id !== state.invoice.id) { throw new Error("invoice not found"); } state.invoice = { ...state.invoice, ...data }; return pickSelect(state.invoice, select); }), }, auditLog: { create: vi.fn(async () => ({ id: "audit-1" })), }, }; const prisma = { ...tx, $transaction: vi.fn(async (callback) => callback(tx)), }; return { prisma, tx, state }; } async function buildApp(options = {}) { const { prisma, tx, state } = buildPrisma(options); const permissionCodes = options.permissionCodes ?? ["expense.withdraw", "invoice.upload"]; const employeeId = options.employeeId ?? OWNER_ID; const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { storageDir: "/tmp", appOrigin: "http://localhost:5173", maxUploadSizeBytes: 10 * 1024 * 1024, invoiceArchiveUnusedDays: 30, budgetControlMode: "warn", appTimezone: "Asia/Shanghai", }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: employeeId, department_id: "dept-1", roleCodes: [], permissionCodes, }; }); 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" }); await app.register(invoicePoolRoutes, { prefix: "/api" }); return { app, prisma, tx, state }; } describe("POST /api/reimbursements/:id/withdraw invoice release", () => { it("rejects a non-participant even when the employee has withdraw permission", async () => { const { app, tx, state } = await buildApp({ employeeId: "77777777-7777-4777-8777-777777777777" }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/withdraw`, headers: { authorization: "Bearer test" }, payload: { comment: "不应允许非参与人撤回" }, }); expect(response.statusCode).toBe(403); expect(response.json().error).toMatchObject({ code: "PERM_001", details: { withdraw: "only_owner_or_submitter" }, }); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(state.reimbursement.status).toBe("reviewing"); await app.close(); }); it("releases bound invoice on withdraw and allows rebinding to the draft reimbursement", async () => { const { app, tx, state } = await buildApp(); const withdrawResponse = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/withdraw`, headers: { authorization: "Bearer test" }, payload: { comment: "撤回补充说明" }, }); expect(withdrawResponse.statusCode).toBe(200); expect(withdrawResponse.json().data).toMatchObject({ reimbursementId: REIMBURSEMENT_ID, outcome: "withdrawn", workflowInstanceId: ACTIVE_INSTANCE_ID, reimbursementStatus: "draft", unboundInvoiceCount: 1, }); expect(tx.invoicePool.updateMany).toHaveBeenCalledWith({ where: { linked_reimbursement_id: REIMBURSEMENT_ID, status: "bound", }, data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, updated_by: OWNER_ID, }, }); expect(state.reimbursement.status).toBe("draft"); expect(state.invoice).toMatchObject({ status: "available", linked_reimbursement_id: null, linked_item_id: null, }); const rebindResponse = await app.inject({ method: "POST", url: `/api/invoice-pool/${INVOICE_ID}/bind`, headers: { authorization: "Bearer test" }, payload: { reimbursementId: REIMBURSEMENT_ID, linkedItemId: ITEM_ID, }, }); expect(rebindResponse.statusCode).toBe(200); expect(rebindResponse.json().data).toMatchObject({ id: INVOICE_ID, status: "bound", linkedReimbursementId: REIMBURSEMENT_ID, linkedItemId: ITEM_ID, }); expect(state.invoice).toMatchObject({ status: "bound", linked_reimbursement_id: REIMBURSEMENT_ID, linked_item_id: ITEM_ID, }); await app.close(); }); it("does not release invoice when withdraw is blocked after approver already processed the workflow", async () => { const { app, tx, state } = await buildApp({ processedTaskCount: 1 }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/withdraw`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { withdraw: "already_processed_by_approver", }, }); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(state.reimbursement.status).toBe("reviewing"); expect(state.invoice).toMatchObject({ status: "bound", linked_reimbursement_id: REIMBURSEMENT_ID, linked_item_id: ITEM_ID, }); await app.close(); }); });