文件
hyb-oa/server/tests/unit/routes/reimbursements.delete.test.js
T

136 行
6.9 KiB
JavaScript

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();
});
});