const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const routes = require("../../../src/routes/reimbursements"); const FORM_ID = "11111111-1111-4111-8111-111111111111"; const ACTOR_ID = "22222222-2222-4222-8222-222222222222"; const OTHER_ID = "33333333-3333-4333-8333-333333333333"; const INVOICE_ID = "44444444-4444-4444-8444-444444444444"; async function setup(options = {}) { let record = { id: FORM_ID, owner_id: ACTOR_ID, submitter_id: OTHER_ID, status: "rejected", deleted_at: null, paid_at: null, ...options.record, }; const invoices = options.invoices ?? [{ id: INVOICE_ID, linked_item_id: "item-1", storage_path: "synthetic.pdf", original_name: "synthetic.pdf", mime_type: "application/pdf", file_size: 20, file_sha256: "synthetic-hash", uploaded_by_employee_id: ACTOR_ID, status: "bound", }]; const tx = { $queryRaw: vi.fn(async (strings) => strings.join("").includes('"invoice_pool"') ? invoices : [record]), workflowInstance: { findFirst: vi.fn(async ({ where }) => where.OR ? (options.active ? { id: "active" } : null) : (options.history === false ? null : { id: "history" })), }, paymentBatchItem: { findFirst: vi.fn(async () => options.payment ? { id: "batch-item" } : null) }, reimbursementAttachment: { createMany: vi.fn(async () => ({ count: invoices.length })) }, reimbursementForm: { update: vi.fn(async ({ data }) => { record = { ...record, ...data }; return record; }), delete: vi.fn(async () => record), }, invoicePool: { updateMany: vi.fn(async () => ({ count: invoices.length })) }, auditLog: { create: vi.fn(async () => ({ id: "audit" })) }, }; const actor = { id: options.actorId ?? ACTOR_ID, permissionCodes: options.permissions ?? ["expense.delete_own"], roleCodes: [] }; const app = Fastify(); app.decorate("prisma", { $transaction: async (callback) => callback(tx) }); app.decorate("config", {}); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("hasPermission", (employee, code) => employee.permissionCodes.includes(code) || employee.permissionCodes.includes("*")); app.decorate("requirePermission", (code) => async (request) => { request.employee = actor; if (!app.hasPermission(actor, code)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); }); app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); await app.register(routes, { prefix: "/api" }); return { app, tx, record }; } describe("reimbursement retained deletion", () => { const apps = []; afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); async function arrange(options) { const result = await setup(options); apps.push(result.app); return { ...result, remove: () => result.app.inject({ method: "DELETE", url: `/api/reimbursements/${FORM_ID}` }) }; } it.each(["rejected", "draft"])("retains history and immutable invoice references for historical %s", async (status) => { const { remove, tx } = await arrange({ record: { status } }); expect((await remove()).statusCode).toBe(200); expect(tx.reimbursementForm.delete).not.toHaveBeenCalled(); expect(tx.reimbursementForm.update).toHaveBeenCalledWith({ where: { id: FORM_ID }, data: { status: "cancelled", deleted_at: expect.any(Date), deleted_by: ACTOR_ID, updated_by: ACTOR_ID }, }); expect(tx.reimbursementAttachment.createMany.mock.calls[0][0].data[0]).toMatchObject({ reimbursement_id: FORM_ID, storage_path: "synthetic.pdf", file_sha256: "synthetic-hash", attachment_type: "invoice", }); expect(tx.invoicePool.updateMany).toHaveBeenCalledWith({ where: { linked_reimbursement_id: FORM_ID, status: "bound" }, data: { status: "available", linked_reimbursement_id: null, linked_item_id: null, updated_by: ACTOR_ID }, }); expect(tx.auditLog.create.mock.calls[0][0].data).toMatchObject({ action: "REIMBURSEMENT_DELETED", extra: { previousStatus: status, retainedHistory: true, releasedInvoices: [{ invoiceId: INVOICE_ID, itemId: "item-1", fileSha256: "synthetic-hash" }] }, }); expect(tx.$queryRaw.mock.calls.map(([strings]) => strings.join(""))[0]).toContain('"reimbursement_forms"'); expect(tx.$queryRaw.mock.calls.map(([strings]) => strings.join(""))[1]).toContain('"invoice_pool"'); }); it("physically deletes only never-submitted drafts", async () => { const { remove, tx } = await arrange({ record: { status: "draft" }, history: false }); expect((await remove()).statusCode).toBe(200); expect(tx.reimbursementForm.delete).toHaveBeenCalledWith({ where: { id: FORM_ID } }); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.reimbursementAttachment.createMany).not.toHaveBeenCalled(); }); it("does not assume rejected records without workflow are safe to erase", async () => { const { remove, tx } = await arrange({ history: false }); expect((await remove()).statusCode).toBe(200); expect(tx.reimbursementForm.delete).not.toHaveBeenCalled(); }); it("permits the submitter and retries without releasing invoices twice", async () => { const { remove, tx } = await arrange({ actorId: OTHER_ID }); expect((await remove()).statusCode).toBe(200); expect((await remove()).statusCode).toBe(200); expect(tx.invoicePool.updateMany).toHaveBeenCalledTimes(1); expect(tx.auditLog.create).toHaveBeenCalledTimes(1); }); it.each([ { actorId: INVOICE_ID, permissions: ["*"] }, { actorId: INVOICE_ID, permissions: ["expense.delete_own", "expense.view_all"] }, { permissions: ["expense.view_all"] }, { actorId: INVOICE_ID, permissions: ["*"], record: { deleted_at: new Date(), status: "cancelled" } }, ])("refuses unauthorized deletion even with full reading or wildcard", async (options) => { const { remove, tx } = await arrange(options); expect((await remove()).statusCode).toBe(403); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); }); it.each([ { record: { status: "reviewing" } }, { record: { status: "approved" } }, { record: { status: "paid" } }, { record: { status: "cancelled" } }, { record: { paid_at: new Date() } }, { active: true }, { payment: true }, { invoices: [{ id: INVOICE_ID, status: "archived" }] }, ])("refuses unsafe state without any write", async (options) => { const { remove, tx } = await arrange(options); const response = await remove(); expect(response.statusCode).toBe(409); expect(response.json().error.details.reasonCode).toBe("reimbursement.status_invalid_delete_status"); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); }); });