181 行
6.5 KiB
JavaScript
181 行
6.5 KiB
JavaScript
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();
|
|
});
|
|
});
|