const Fastify = require("fastify"); const exportsRoutes = require("../../../src/routes/exports"); const { AppError } = require("../../../src/utils/AppError"); const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; async function buildApp(actor) { const app = Fastify(); const findMany = vi.fn().mockResolvedValue([ { id: "11111111-1111-4111-8111-111111111111", employee_no: "E001", name: "Alice", phone: "13800000000", email: "alice@example.com", status: "active", title: "经理", hire_date: new Date("2026-01-10T00:00:00.000Z"), created_at: new Date("2026-03-28T10:00:00.000Z"), department: { name: "行政部" }, }, ]); const count = vi.fn().mockResolvedValue(1); const auditCreate = vi.fn().mockResolvedValue({}); 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("hasPermission", (employee, permissionCode) => { const permissionCodes = employee.permissionCodes ?? []; return permissionCodes.includes("*") || permissionCodes.includes(permissionCode); }); app.decorate("prisma", { employee: { findMany, count, }, auditLog: { create: auditCreate, }, }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }, }); } return reply.status(500).send({ error: { code: "SYS_001" } }); }); await app.register(exportsRoutes, { prefix: "/api" }); return { app, findMany, count, auditCreate }; } describe("employee exports route", () => { it("exports filtered employees for system.users", async () => { const { app, findMany, count, auditCreate } = await buildApp({ id: "actor-1", roleCodes: ["administration"], permissionCodes: ["system.users"], }); const response = await app.inject({ method: "GET", url: `/api/exports/employees?format=csv&status=active&departmentId=${DEPARTMENT_ID}&q=alice&title=%E7%BB%8F%E7%90%86&managerId=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb&hireDateFrom=2026-01-01&hireDateTo=2026-01-31&employmentStatus=active`, }); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("text/csv"); expect(response.headers["content-disposition"]).toContain("employees-"); expect(findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: "active", department_id: DEPARTMENT_ID, direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", employment_status: "active", hire_date: expect.objectContaining({ gte: new Date("2026-01-01T00:00:00.000Z"), lte: new Date("2026-01-31T23:59:59.999Z"), }), AND: expect.arrayContaining([ expect.objectContaining({ OR: expect.arrayContaining([ expect.objectContaining({ name: expect.any(Object) }), expect.objectContaining({ employee_no: expect.any(Object) }), ]), }), expect.objectContaining({ title: expect.objectContaining({ contains: "经理" }), }), ]), }), }), ); expect(count).toHaveBeenCalledWith({ where: expect.objectContaining({ status: "active", department_id: DEPARTMENT_ID, direct_manager_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", }), }); expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "EXPORT_EMPLOYEES", extra: expect.objectContaining({ title: "经理", managerId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", hireDateFrom: "2026-01-01", hireDateTo: "2026-01-31", employmentStatus: "active", }), }), }), ); await app.close(); }); it("rejects non system.users access", async () => { const { app } = await buildApp({ id: "actor-2", roleCodes: ["manager"], permissionCodes: ["report.dashboard.view"], }); const response = await app.inject({ method: "GET", url: "/api/exports/employees?format=csv", }); expect(response.statusCode).toBe(403); await app.close(); }); }); describe("employee export calendar filters", () => { const actor = { id: "synthetic-manager", permissionCodes: ["system.users"] }; it.each(["csv", "xlsx"].flatMap((format) => ["hireDateFrom", "hireDateTo"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [format, field, date]))))("rejects %s %s=%s before reads or export audit", async (format, field, date) => { const { app, findMany, count, auditCreate } = await buildApp(actor); try { const response = await app.inject({ method: "GET", url: `/api/exports/employees?format=${format}&${field}=${date}` }); expect(response.statusCode).toBe(400); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: "employee_directory.query_invalid", formErrors: [], fieldErrors: { [field]: expect.arrayContaining(["invalid_calendar_date"]) } } }); for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); expect(response.headers["content-disposition"]).toBeUndefined(); } finally { await app.close(); } }); it.each(["csv", "xlsx"])("preserves valid %s UTC bounds", async (format) => { const { app, findMany, count } = await buildApp(actor); try { const response = await app.inject({ method: "GET", url: `/api/exports/employees?format=${format}&hireDateFrom=2024-02-29&hireDateTo=2024-04-30` }); expect(response.statusCode).toBe(200); for (const fn of [findMany, count]) expect(fn.mock.calls[0][0].where.hire_date).toEqual({ gte: new Date("2024-02-29T00:00:00.000Z"), lte: new Date("2024-04-30T23:59:59.999Z") }); } finally { await app.close(); } }); it("preserves omitted filters and reversed-range rejection", async () => { const { app, findMany, count, auditCreate } = await buildApp(actor); try { const omitted = await app.inject({ method: "GET", url: "/api/exports/employees" }); expect(omitted.statusCode).toBe(200); expect(count.mock.calls[0][0].where).not.toHaveProperty("hire_date"); for (const fn of [findMany, count, auditCreate]) fn.mockClear(); const reversed = await app.inject({ method: "GET", url: "/api/exports/employees?hireDateFrom=2024-05-01&hireDateTo=2024-04-30" }); expect(reversed.statusCode).toBe(400); expect(reversed.json().error.details).toEqual({ reasonCode: "employee_directory.hire_date_range_invalid", hireDateTo: "must_be_after_or_equal_hireDateFrom" }); for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); } finally { await app.close(); } }); }); it("explains invalid employee export format before reads/audit", async () => { const { app, findMany, count, auditCreate } = await buildApp({ id: "synthetic-actor", permissionCodes: ["system.users"] }); try { const response = await app.inject({ method: "GET", url: "/api/exports/employees?format=invalid" }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: "employee_directory.query_invalid", fieldErrors: { format: expect.any(Array) } }); for (const fn of [findMany, count, auditCreate]) expect(fn).not.toHaveBeenCalled(); } finally { await app.close(); } });