354 行
10 KiB
JavaScript
354 行
10 KiB
JavaScript
const Fastify = require("fastify");
|
|
|
|
const reimbursementTemplatesRoutes = require("../../../src/routes/reimbursementTemplates");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const TEMPLATE_ID = "11111111-1111-4111-8111-111111111111";
|
|
const EXPENSE_TYPE_ID = "22222222-2222-4222-8222-222222222222";
|
|
const DISABLED_EXPENSE_TYPE_ID = "33333333-3333-4333-8333-333333333333";
|
|
|
|
function cloneTemplate(item) {
|
|
return {
|
|
...item,
|
|
items: Array.isArray(item.items) ? item.items.map((entry) => ({ ...entry })) : [],
|
|
created_at: new Date(item.created_at),
|
|
updated_at: new Date(item.updated_at),
|
|
};
|
|
}
|
|
|
|
function buildTemplate(overrides = {}) {
|
|
return {
|
|
id: TEMPLATE_ID,
|
|
name: "国内差旅报销",
|
|
description: "机票、火车票与市内交通占位模板",
|
|
items: [
|
|
{
|
|
expenseTypeId: EXPENSE_TYPE_ID,
|
|
description: "默认交通费",
|
|
defaultAmount: "88.80",
|
|
},
|
|
],
|
|
sort_order: 10,
|
|
enabled: true,
|
|
created_at: new Date("2026-04-08T12:00:00.000Z"),
|
|
updated_at: new Date("2026-04-08T12:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function buildApp(options = {}) {
|
|
const app = Fastify();
|
|
const actor = options.actor ?? {
|
|
id: ACTOR_ID,
|
|
permissionCodes: ["system.settings"],
|
|
};
|
|
|
|
const state = {
|
|
templates: (options.templates ?? [buildTemplate()]).map((item) => cloneTemplate(item)),
|
|
expenseTypes: options.expenseTypes ?? [
|
|
{
|
|
id: EXPENSE_TYPE_ID,
|
|
code: "travel_airfare",
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: DISABLED_EXPENSE_TYPE_ID,
|
|
code: "transport",
|
|
enabled: false,
|
|
},
|
|
],
|
|
auditLogs: [],
|
|
};
|
|
|
|
function buildPrismaLike(stateRef) {
|
|
return {
|
|
reimbursementTemplate: {
|
|
findMany: vi.fn(async ({ where, orderBy } = {}) => {
|
|
let items = stateRef.templates.filter((item) => {
|
|
if (typeof where?.enabled === "boolean" && item.enabled !== where.enabled) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (Array.isArray(orderBy)) {
|
|
items = items.sort((left, right) => {
|
|
if (left.sort_order !== right.sort_order) {
|
|
return left.sort_order - right.sort_order;
|
|
}
|
|
return left.name.localeCompare(right.name, "zh-CN");
|
|
});
|
|
}
|
|
|
|
return items.map((item) => cloneTemplate(item));
|
|
}),
|
|
findUnique: vi.fn(async ({ where }) => {
|
|
const found = stateRef.templates.find((item) => item.id === where.id) ?? null;
|
|
return found ? cloneTemplate(found) : null;
|
|
}),
|
|
create: vi.fn(async ({ data }) => {
|
|
const record = buildTemplate({
|
|
id: `66666666-6666-4666-8666-${String(stateRef.templates.length + 1).padStart(12, "0")}`,
|
|
name: data.name,
|
|
description: data.description ?? null,
|
|
items: data.items ?? [],
|
|
sort_order: data.sort_order ?? 0,
|
|
enabled: data.enabled ?? true,
|
|
created_at: data.created_at,
|
|
updated_at: data.updated_at,
|
|
});
|
|
stateRef.templates.push(record);
|
|
return cloneTemplate(record);
|
|
}),
|
|
update: vi.fn(async ({ where, data }) => {
|
|
const index = stateRef.templates.findIndex((item) => item.id === where.id);
|
|
if (index < 0) {
|
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
|
}
|
|
const next = {
|
|
...stateRef.templates[index],
|
|
...data,
|
|
};
|
|
stateRef.templates[index] = next;
|
|
return cloneTemplate(next);
|
|
}),
|
|
findFirst: vi.fn(async ({ where }) => {
|
|
const found = stateRef.templates.find((item) => item.id === where.id && item.enabled === where.enabled) ?? null;
|
|
return found ? cloneTemplate(found) : null;
|
|
}),
|
|
},
|
|
expenseType: {
|
|
findMany: vi.fn(async ({ where }) => {
|
|
const ids = where?.id?.in ?? [];
|
|
return stateRef.expenseTypes.filter(
|
|
(item) => ids.includes(item.id) && item.enabled === where.enabled,
|
|
);
|
|
}),
|
|
},
|
|
auditLog: {
|
|
create: vi.fn(async ({ data }) => {
|
|
stateRef.auditLogs.push(data);
|
|
return { id: `audit-${stateRef.auditLogs.length}` };
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
const prisma = buildPrismaLike(state);
|
|
prisma.$transaction = vi.fn(async (callback) => {
|
|
const txState = {
|
|
templates: state.templates.map((item) => cloneTemplate(item)),
|
|
expenseTypes: state.expenseTypes.map((item) => ({ ...item })),
|
|
auditLogs: state.auditLogs.map((item) => ({ ...item })),
|
|
};
|
|
const tx = buildPrismaLike(txState);
|
|
const result = await callback(tx);
|
|
state.templates = txState.templates;
|
|
state.expenseTypes = txState.expenseTypes;
|
|
state.auditLogs = txState.auditLogs;
|
|
return result;
|
|
});
|
|
|
|
app.decorate("authenticate", async (request) => {
|
|
request.employee = actor;
|
|
});
|
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
|
request.employee = actor;
|
|
const permissionCodes = actor.permissionCodes ?? [];
|
|
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
});
|
|
app.decorate("prisma", prisma);
|
|
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 ?? null,
|
|
},
|
|
});
|
|
});
|
|
|
|
await app.register(reimbursementTemplatesRoutes, { prefix: "/api" });
|
|
return { app, state, prisma };
|
|
}
|
|
|
|
describe("reimbursement template routes", () => {
|
|
it("allows authenticated users to list templates with enabled filter", async () => {
|
|
const { app } = await buildApp({
|
|
actor: {
|
|
id: ACTOR_ID,
|
|
permissionCodes: [],
|
|
},
|
|
templates: [
|
|
buildTemplate(),
|
|
buildTemplate({
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
name: "停用模板",
|
|
enabled: false,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/reimbursement-templates?enabled=true",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual({
|
|
enabled: true,
|
|
items: [
|
|
expect.objectContaining({
|
|
id: TEMPLATE_ID,
|
|
name: "国内差旅报销",
|
|
enabled: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("creates, updates and soft deletes a template with audit logs", async () => {
|
|
const { app, state } = await buildApp();
|
|
|
|
const createResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/api/reimbursement-templates",
|
|
payload: {
|
|
name: "办公用品报销",
|
|
description: "默认办公用品采购占位",
|
|
items: [
|
|
{
|
|
expenseTypeId: EXPENSE_TYPE_ID,
|
|
description: "办公耗材",
|
|
defaultAmount: "123.40",
|
|
},
|
|
],
|
|
sortOrder: 5,
|
|
enabled: true,
|
|
},
|
|
});
|
|
|
|
expect(createResponse.statusCode).toBe(200);
|
|
const createdId = createResponse.json().data.id;
|
|
expect(state.auditLogs).toHaveLength(1);
|
|
expect(state.auditLogs[0].action).toBe("REIMBURSEMENT_TEMPLATE_CREATED");
|
|
|
|
const updateResponse = await app.inject({
|
|
method: "PATCH",
|
|
url: `/api/reimbursement-templates/${createdId}`,
|
|
payload: {
|
|
name: "办公用品报销模板",
|
|
enabled: false,
|
|
},
|
|
});
|
|
|
|
expect(updateResponse.statusCode).toBe(200);
|
|
expect(updateResponse.json().data).toEqual(
|
|
expect.objectContaining({
|
|
id: createdId,
|
|
name: "办公用品报销模板",
|
|
enabled: false,
|
|
}),
|
|
);
|
|
|
|
const deleteResponse = await app.inject({
|
|
method: "DELETE",
|
|
url: `/api/reimbursement-templates/${createdId}`,
|
|
});
|
|
|
|
expect(deleteResponse.statusCode).toBe(200);
|
|
expect(deleteResponse.json().data).toEqual(
|
|
expect.objectContaining({
|
|
id: createdId,
|
|
enabled: false,
|
|
}),
|
|
);
|
|
expect(state.auditLogs.map((item) => item.action)).toEqual([
|
|
"REIMBURSEMENT_TEMPLATE_CREATED",
|
|
"REIMBURSEMENT_TEMPLATE_UPDATED",
|
|
]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects invalid item schema and disabled expense types", async () => {
|
|
const { app } = await buildApp();
|
|
|
|
const invalidSchemaResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/api/reimbursement-templates",
|
|
payload: {
|
|
name: "坏模板",
|
|
items: [
|
|
{
|
|
expenseTypeId: EXPENSE_TYPE_ID,
|
|
description: "无效金额",
|
|
defaultAmount: 18.8,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(invalidSchemaResponse.statusCode).toBe(400);
|
|
|
|
const invalidExpenseTypeResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/api/reimbursement-templates",
|
|
payload: {
|
|
name: "禁用费用类型模板",
|
|
items: [
|
|
{
|
|
expenseTypeId: DISABLED_EXPENSE_TYPE_ID,
|
|
description: "旧费用类型",
|
|
defaultAmount: "9.90",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(invalidExpenseTypeResponse.statusCode).toBe(409);
|
|
expect(invalidExpenseTypeResponse.json().error).toEqual(
|
|
expect.objectContaining({
|
|
code: "VALID_001",
|
|
details: expect.objectContaining({
|
|
items: [
|
|
expect.objectContaining({
|
|
expenseTypeId: DISABLED_EXPENSE_TYPE_ID,
|
|
reason: "expense_type_not_found_or_disabled",
|
|
}),
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("blocks management actions without system.settings", async () => {
|
|
const { app } = await buildApp({
|
|
actor: {
|
|
id: ACTOR_ID,
|
|
permissionCodes: [],
|
|
},
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/api/reimbursement-templates",
|
|
payload: {
|
|
name: "无权限模板",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
|
|
await app.close();
|
|
});
|
|
});
|