227 行
6.4 KiB
JavaScript
227 行
6.4 KiB
JavaScript
const Fastify = require("fastify");
|
|
|
|
const reimbursementRoutes = require("../../../src/routes/reimbursements");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
|
|
async function buildApp(options = {}) {
|
|
const app = Fastify();
|
|
const actor = options.actor ?? {
|
|
id: ACTOR_ID,
|
|
roleCodes: ["manager"],
|
|
permissionCodes: ["expense.view_all"],
|
|
};
|
|
const queryRaw =
|
|
options.queryRaw ??
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce([
|
|
{ status: "draft", count: 2, total_amount: "100.00" },
|
|
{ status: "paid", count: 1, total_amount: "80.00" },
|
|
])
|
|
.mockResolvedValueOnce([{ avg_processing_days: 3.5 }]);
|
|
|
|
app.decorate("authenticate", async (request) => {
|
|
request.employee = actor;
|
|
});
|
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
|
const permissionCodes = employee.permissionCodes ?? [];
|
|
return permissionCodes.includes("*") || 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("config", { appTimezone: "Asia/Shanghai" });
|
|
app.decorate("prisma", {
|
|
$queryRaw: queryRaw,
|
|
});
|
|
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(reimbursementRoutes, { prefix: "/api" });
|
|
return { app, queryRaw };
|
|
}
|
|
|
|
describe("reimbursements stats routes", () => {
|
|
it("returns summary with filters and zero-filled status buckets", async () => {
|
|
const { app, queryRaw } = await buildApp();
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/api/reimbursements/stats/summary?dateFrom=2026-01-01&dateTo=2026-03-31&departmentId=${DEPARTMENT_ID}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
|
expect(queryRaw.mock.calls[0][0].values).toEqual(
|
|
expect.arrayContaining([
|
|
new Date("2026-01-01T00:00:00.000Z"),
|
|
new Date("2026-03-31T00:00:00.000Z"),
|
|
DEPARTMENT_ID,
|
|
ACTOR_ID,
|
|
]),
|
|
);
|
|
expect(response.json().data).toEqual({
|
|
totalCount: 3,
|
|
totalAmount: "180.00",
|
|
byStatus: {
|
|
draft: { count: 2, totalAmount: "100.00" },
|
|
reviewing: { count: 0, totalAmount: "0.00" },
|
|
approved: { count: 0, totalAmount: "0.00" },
|
|
paid: { count: 1, totalAmount: "80.00" },
|
|
rejected: { count: 0, totalAmount: "0.00" },
|
|
cancelled: { count: 0, totalAmount: "0.00" },
|
|
},
|
|
avgProcessingDays: 3.5,
|
|
});
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("returns empty summary when there is no matching data", async () => {
|
|
const { app } = await buildApp({
|
|
queryRaw: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([{ avg_processing_days: null }]),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/reimbursements/stats/summary",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toMatchObject({
|
|
totalCount: 0,
|
|
totalAmount: "0.00",
|
|
avgProcessingDays: null,
|
|
byStatus: {
|
|
draft: { count: 0, totalAmount: "0.00" },
|
|
reviewing: { count: 0, totalAmount: "0.00" },
|
|
approved: { count: 0, totalAmount: "0.00" },
|
|
paid: { count: 0, totalAmount: "0.00" },
|
|
rejected: { count: 0, totalAmount: "0.00" },
|
|
cancelled: { count: 0, totalAmount: "0.00" },
|
|
},
|
|
});
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("returns monthly trends with count and paid metrics", async () => {
|
|
const { app } = await buildApp({
|
|
queryRaw: vi.fn().mockResolvedValue([
|
|
{
|
|
period_start: new Date("2026-02-01T00:00:00.000Z"),
|
|
count: 4,
|
|
amount: "220.00",
|
|
paid_count: 2,
|
|
paid_amount: "120.00",
|
|
},
|
|
]),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/reimbursements/stats/trend?granularity=month",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data.items).toEqual([
|
|
{
|
|
period: "2026-02",
|
|
count: 4,
|
|
amount: "220.00",
|
|
paidCount: 2,
|
|
paidAmount: "120.00",
|
|
},
|
|
]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("returns top expense types and approval bottlenecks", async () => {
|
|
const queryRaw = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([
|
|
{
|
|
expense_type_id: "type-1",
|
|
expense_type_name: "差旅交通",
|
|
count: 5,
|
|
total_amount: "520.00",
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
assignee_id: null,
|
|
assignee_role: "manager",
|
|
assignee_name: null,
|
|
pending_count: 3,
|
|
oldest_pending_days: 5,
|
|
},
|
|
]);
|
|
const { app } = await buildApp({ queryRaw });
|
|
|
|
const topResponse = await app.inject({
|
|
method: "GET",
|
|
url: "/api/reimbursements/stats/top-expense-types?limit=5",
|
|
});
|
|
const bottleneckResponse = await app.inject({
|
|
method: "GET",
|
|
url: "/api/reimbursements/stats/approval-bottleneck",
|
|
});
|
|
|
|
expect(topResponse.statusCode).toBe(200);
|
|
expect(topResponse.json().data.items).toEqual([
|
|
{
|
|
expenseTypeId: "type-1",
|
|
expenseTypeName: "差旅交通",
|
|
count: 5,
|
|
totalAmount: "520.00",
|
|
},
|
|
]);
|
|
expect(bottleneckResponse.statusCode).toBe(200);
|
|
expect(bottleneckResponse.json().data.items).toEqual([
|
|
{
|
|
assigneeId: null,
|
|
assigneeName: "主管",
|
|
pendingCount: 3,
|
|
oldestPendingDays: 5,
|
|
},
|
|
]);
|
|
|
|
await app.close();
|
|
});
|
|
|
|
it("rejects users without expense.view_all permission", async () => {
|
|
const { app } = await buildApp({
|
|
actor: {
|
|
id: ACTOR_ID,
|
|
roleCodes: ["employee"],
|
|
permissionCodes: ["expense.view_own"],
|
|
},
|
|
queryRaw: vi.fn(),
|
|
});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/reimbursements/stats/summary",
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
|
|
await app.close();
|
|
});
|
|
});
|