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 OTHER_REIMBURSEMENT_ID = "22222222-2222-4222-8222-222222222222"; const INVOICE_ID = "33333333-3333-4333-8333-333333333333"; const ITEM_ID = "44444444-4444-4444-8444-444444444444"; const ACTOR_ID = "55555555-5555-4555-8555-555555555555"; const OTHER_EMPLOYEE_ID = "66666666-6666-4666-8666-666666666666"; function sqlText(strings) { return Array.isArray(strings) ? strings.join("?") : String(strings); } async function buildApp(options = {}) { const actor = options.actor ?? { id: ACTOR_ID, permissionCodes: ["expense.edit_own"], roleCodes: [], }; const invoice = { id: INVOICE_ID, status: "bound", linked_reimbursement_id: REIMBURSEMENT_ID, linked_item_id: null, ...options.invoice, }; const reimbursement = { id: REIMBURSEMENT_ID, owner_id: ACTOR_ID, submitter_id: ACTOR_ID, status: "draft", ...options.reimbursement, }; const tx = { $queryRaw: vi.fn(async (strings) => { const sql = sqlText(strings); if (sql.includes('FROM "invoice_pool"')) return options.invoiceMissing ? [] : [invoice]; if (sql.includes('FROM "reimbursement_forms"')) return options.reimbursementMissing ? [] : [reimbursement]; return []; }), reimbursementItem: { findFirst: vi.fn(async () => options.itemMissing ? null : { id: ITEM_ID }), }, invoicePool: { update: vi.fn(async ({ data }) => ({ ...invoice, ...data })), }, auditLog: { create: vi.fn(async () => ({ id: "audit-1" })), }, }; const prisma = { $transaction: vi.fn(async (callback) => callback(tx)), }; const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", reimbursementInvoiceGuardEnabled: true, }); app.decorate("hasPermission", (employee, permissionCode) => ( employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permissionCode) )); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; if (!app.hasPermission(actor, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {}, }, })); await app.register(reimbursementRoutes, { prefix: "/api" }); return { app, prisma, tx }; } function requestPayload() { return { method: "PATCH", url: `/api/reimbursements/${REIMBURSEMENT_ID}/invoices/${INVOICE_ID}/item-link`, payload: { linkedItemId: ITEM_ID }, }; } describe("PATCH reimbursement invoice item link", () => { it("atomically links an unassigned bound invoice to an item in the same reimbursement", async () => { const { app, tx } = await buildApp(); const response = await app.inject(requestPayload()); expect(response.statusCode, response.body).toBe(200); expect(response.json().data).toEqual({ invoiceId: INVOICE_ID, reimbursementId: REIMBURSEMENT_ID, linkedItemId: ITEM_ID, }); expect(tx.reimbursementItem.findFirst).toHaveBeenCalledWith({ where: { id: ITEM_ID, reimbursement_id: REIMBURSEMENT_ID }, select: { id: true }, }); expect(tx.invoicePool.update).toHaveBeenCalledWith({ where: { id: INVOICE_ID }, data: { linked_item_id: ITEM_ID, updated_by: ACTOR_ID }, }); expect(tx.auditLog.create).toHaveBeenCalledTimes(1); await app.close(); }); it.each([ ["submitter participant", { owner_id: OTHER_EMPLOYEE_ID, submitter_id: ACTOR_ID, status: "draft" }], ["rejected form", { owner_id: ACTOR_ID, submitter_id: ACTOR_ID, status: "rejected" }], ])("allows %s to restore the item link", async (_label, reimbursement) => { const { app, tx } = await buildApp({ reimbursement }); const response = await app.inject(requestPayload()); expect(response.statusCode, response.body).toBe(200); expect(tx.invoicePool.update).toHaveBeenCalledTimes(1); expect(tx.auditLog.create).toHaveBeenCalledTimes(1); await app.close(); }); it.each([ ["nonparticipant", { reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID } }, 403], ["nonparticipant with a foreign invoice", { reimbursement: { owner_id: OTHER_EMPLOYEE_ID, submitter_id: OTHER_EMPLOYEE_ID }, invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID }, }, 403], ["submitted reimbursement", { reimbursement: { status: "reviewing" } }, 409], ["item from another reimbursement", { itemMissing: true }, 409], ["invoice already assigned", { invoice: { linked_item_id: ITEM_ID } }, 409], ["invoice belongs to another reimbursement", { invoice: { linked_reimbursement_id: OTHER_REIMBURSEMENT_ID } }, 409], ["invoice is no longer bound", { invoice: { status: "available" } }, 409], ])("rejects %s with zero writes", async (_label, options, expectedStatus) => { const { app, tx } = await buildApp(options); const response = await app.inject(requestPayload()); expect(response.statusCode).toBe(expectedStatus); expect(tx.invoicePool.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("returns not found for a missing invoice only after validating the reimbursement participant", async () => { const { app, tx } = await buildApp({ invoiceMissing: true }); const response = await app.inject(requestPayload()); expect(response.statusCode).toBe(404); expect(tx.$queryRaw).toHaveBeenCalledTimes(2); expect(tx.invoicePool.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("rejects an actor without the own-expense edit permission before reading rows", async () => { const { app, tx } = await buildApp({ actor: { id: ACTOR_ID, permissionCodes: [], roleCodes: [] }, }); const response = await app.inject(requestPayload()); expect(response.statusCode).toBe(403); expect(tx.$queryRaw).not.toHaveBeenCalled(); expect(tx.invoicePool.update).not.toHaveBeenCalled(); await app.close(); }); });