115 行
5.5 KiB
JavaScript
115 行
5.5 KiB
JavaScript
const Fastify = require("fastify");
|
|
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
|
|
|
const FORM_ID = "11111111-1111-4111-8111-111111111111";
|
|
const ACTOR_ID = "22222222-2222-4222-8222-222222222222";
|
|
const ITEM_ID = "33333333-3333-4333-8333-333333333333";
|
|
const TYPE_ID = "44444444-4444-4444-8444-444444444444";
|
|
|
|
async function buildApp({ status = "draft", participant = true, expenseTypeId = null, missingItem = false, typeEnabled = true } = {}) {
|
|
const item = {
|
|
id: ITEM_ID,
|
|
reimbursement_id: FORM_ID,
|
|
expense_type_id: expenseTypeId,
|
|
expense_date: new Date("2026-09-09T00:00:00Z"),
|
|
amount: "100.00",
|
|
description: "合成测试支出",
|
|
};
|
|
const tx = {
|
|
$queryRaw: vi.fn(async () => [{
|
|
id: FORM_ID, owner_id: participant ? ACTOR_ID : TYPE_ID, submitter_id: TYPE_ID, status,
|
|
}]),
|
|
expenseType: { findUnique: vi.fn(async () => ({ id: TYPE_ID, enabled: typeEnabled, category: "office" })) },
|
|
reimbursementItem: {
|
|
findFirst: vi.fn(async () => item),
|
|
findMany: vi.fn(async () => missingItem ? [] : [item]),
|
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
|
create: vi.fn(async ({ data }) => ({ id: ITEM_ID, ...data })),
|
|
update: vi.fn(async ({ data }) => Object.assign(item, data)),
|
|
aggregate: vi.fn(async () => ({ _sum: { amount: "100.00" } })),
|
|
},
|
|
reimbursementForm: { update: vi.fn(async () => ({})) },
|
|
};
|
|
const app = Fastify();
|
|
app.decorate("config", {});
|
|
app.decorate("prisma", { $transaction: async (callback) => callback(tx) });
|
|
app.decorate("requirePermission", () => async (request) => { request.employee = { id: ACTOR_ID }; });
|
|
app.decorate("authenticate", async (request) => { request.employee = { id: ACTOR_ID }; });
|
|
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({
|
|
error: { code: error.code, details: error.details },
|
|
}));
|
|
await app.register(reimbursementRoutes, { prefix: "/api" });
|
|
return { app, tx };
|
|
}
|
|
|
|
describe("draft reimbursement classification", () => {
|
|
it("classifies only explicitly selected items without changing amount, date, description or approval basis", async () => {
|
|
const { app, tx } = await buildApp();
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items/classify`,
|
|
payload: { itemIds: [ITEM_ID, ITEM_ID], expenseTypeId: TYPE_ID } });
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
expect(tx.reimbursementItem.updateMany).toHaveBeenCalledWith({
|
|
where: { reimbursement_id: FORM_ID, id: { in: [ITEM_ID] } },
|
|
data: { expense_type_id: TYPE_ID, updated_by: ACTOR_ID },
|
|
});
|
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each([
|
|
[{ missingItem: true }, 409], [{ typeEnabled: false }, 409],
|
|
[{ participant: false }, 403], [{ status: "reviewing" }, 409],
|
|
])("rejects a whole classification batch without partial writes: %j", async (options, expectedStatus) => {
|
|
const { app, tx } = await buildApp(options);
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items/classify`,
|
|
payload: { itemIds: [ITEM_ID], expenseTypeId: TYPE_ID } });
|
|
expect(response.statusCode, response.body).toBe(expectedStatus);
|
|
expect(tx.reimbursementItem.updateMany).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each(["draft", "rejected"])("saves an explicitly unclassified item in %s without guessing a type", async (status) => {
|
|
const { app, tx } = await buildApp({ status });
|
|
try {
|
|
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${FORM_ID}/items`, payload: {
|
|
expenseTypeId: null, expenseDate: "2026-09-09", amount: "100.00", description: "",
|
|
} });
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
expect(response.json().data.item.expenseTypeId).toBeNull();
|
|
expect(tx.expenseType.findUnique).not.toHaveBeenCalled();
|
|
expect(tx.reimbursementItem.create.mock.calls[0][0].data.expense_type_id).toBeNull();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each([
|
|
[null, { description: "修正合成说明" }, null],
|
|
[TYPE_ID, { expenseTypeId: null }, null],
|
|
[null, { expenseTypeId: TYPE_ID }, TYPE_ID],
|
|
])("edits classification without nullish fallback or fabricated defaults", async (expenseTypeId, payload, expected) => {
|
|
const { app, tx } = await buildApp({ expenseTypeId });
|
|
try {
|
|
const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${FORM_ID}/items/${ITEM_ID}`, payload });
|
|
expect(response.statusCode, response.body).toBe(200);
|
|
expect(response.json().data.item.expenseTypeId).toBe(expected);
|
|
if (expected === null) expect(tx.expenseType.findUnique).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
|
|
it.each([
|
|
[{ participant: false }, 403],
|
|
[{ status: "reviewing" }, 409],
|
|
[{ status: "approved" }, 409],
|
|
[{ status: "paid" }, 409],
|
|
])("does not widen maintenance permissions or states: %j", async (options, statusCode) => {
|
|
const { app, tx } = await buildApp(options);
|
|
try {
|
|
const response = await app.inject({ method: "PATCH", url: `/api/reimbursements/${FORM_ID}/items/${ITEM_ID}`, payload: { expenseTypeId: null } });
|
|
expect(response.statusCode, response.body).toBe(statusCode);
|
|
expect(tx.reimbursementItem.update).not.toHaveBeenCalled();
|
|
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
|
} finally { await app.close(); }
|
|
});
|
|
});
|