const Fastify = require("fastify"); const exportsRoutes = require("../../../src/routes/exports"); const { AppError } = require("../../../src/utils/AppError"); const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const OTHER_EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const OTHER_DEPARTMENT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; function buildEmployeeRow(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "E001", name: "Alice", title: "员工", employment_status: "active", department_id: DEPARTMENT_ID, status: "active", department: { id: DEPARTMENT_ID, name: "行政部", }, ...overrides, }; } function buildPresenceRow(overrides = {}) { return { id: "presence-1", employee_id: EMPLOYEE_ID, date: new Date("2026-03-27T00:00:00.000Z"), status: "office", time_range: "full_day", note: "补录", source: "admin_override", updated_at: new Date("2026-03-27T08:00:00.000Z"), ...overrides, }; } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, roleCodes: ["hr"], permissionCodes: ["system.users"], }; const employees = options.employees ?? [buildEmployeeRow()]; const presenceRows = options.presenceRows ?? []; const leaveRows = options.leaveRows ?? []; const travelRows = options.travelRows ?? []; const employeeFindMany = vi.fn(async ({ where, select }) => { if (select?.id === true && Object.keys(select).length === 1) { return employees .filter((item) => item.status === (where?.status ?? item.status)) .map((item) => ({ id: item.id })); } return employees .filter((item) => item.status === (where?.status ?? item.status)) .filter((item) => !where?.department_id || item.department_id === where.department_id) .filter((item) => !where?.id || item.id === where.id) .map((item) => ({ id: item.id, employee_no: item.employee_no, name: item.name, title: item.title, employment_status: item.employment_status, department: item.department, })); }); const employeePresenceFindMany = vi.fn(async ({ where }) => presenceRows.filter((row) => { if (!where?.employee_id?.in?.includes(row.employee_id)) return false; if (where?.date?.gte && row.date < where.date.gte) return false; if (where?.date?.lte && row.date > where.date.lte) return false; return true; }), ); const leaveFindMany = vi.fn(async ({ where }) => leaveRows.filter((row) => { if (!where?.owner_id?.in?.includes(row.owner_id)) return false; if (row.status && where?.status && row.status !== where.status) return false; if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false; if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false; return true; }), ); const travelFindMany = vi.fn(async ({ where }) => travelRows.filter((row) => { if (!where?.applicant_employee_id?.in?.includes(row.applicant_employee_id)) return false; if (row.status && where?.status && row.status !== where.status) return false; if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false; if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false; return true; }), ); 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("config", { appTimezone: "Asia/Shanghai", }); app.decorate("prisma", { employee: { findMany: employeeFindMany, }, employeePresence: { findMany: employeePresenceFindMany, }, leaveRequest: { findMany: leaveFindMany, }, travelRequest: { findMany: travelFindMany, }, auditLog: { create: auditCreate, }, }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ error: { message: error.message } }); }); await app.register(exportsRoutes, { prefix: "/api" }); return { app, employeeFindMany, auditCreate }; } describe("presence history exports route", () => { it("exports filtered presence history for system.users", async () => { const { app, employeeFindMany, auditCreate } = await buildApp({ employees: [ buildEmployeeRow(), buildEmployeeRow({ id: OTHER_EMPLOYEE_ID, employee_no: "E002", name: "Bob", department_id: OTHER_DEPARTMENT_ID, department: { id: OTHER_DEPARTMENT_ID, name: "市场部", }, }), ], presenceRows: [ buildPresenceRow(), buildPresenceRow({ id: "presence-2", employee_id: OTHER_EMPLOYEE_ID, status: "remote", source: "manual", }), ], }); const response = await app.inject({ method: "GET", url: `/api/exports/presence-history?format=csv&dateFrom=2026-03-27&dateTo=2026-03-27&departmentId=${DEPARTMENT_ID}&employeeId=${EMPLOYEE_ID}&status=office`, }); expect(response.statusCode).toBe(200); expect(response.headers["content-type"]).toContain("text/csv"); expect(response.headers["content-disposition"]).toContain("presence-history-"); expect(response.body).toContain("日期,工号,姓名,部门,岗位,状态,时间段,来源,备注"); expect(response.body).toContain("E001"); expect(response.body).toContain("管理员修正"); expect(employeeFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: "active", department_id: DEPARTMENT_ID, id: EMPLOYEE_ID, }), }), ); expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "EXPORT_PRESENCE_HISTORY", extra: expect.objectContaining({ matchedTotal: 1, departmentId: DEPARTMENT_ID, employeeId: EMPLOYEE_ID, status: "office", }), }), }), ); await app.close(); }); it("rejects exports larger than the hard limit", async () => { const employees = Array.from({ length: 162 }, (_, index) => buildEmployeeRow({ id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, employee_no: `E${String(index + 1).padStart(4, "0")}`, name: `员工${index + 1}`, }), ); const { app, auditCreate } = await buildApp({ employees, }); const response = await app.inject({ method: "GET", url: "/api/exports/presence-history?format=csv&dateFrom=2026-02-26&dateTo=2026-03-28", }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ export: "too_large", reasonCode: "export.presence_too_large" }); expect(auditCreate).not.toHaveBeenCalled(); await app.close(); }); it("rejects non system.users access", async () => { const { app } = await buildApp({ actor: { id: ACTOR_ID, roleCodes: ["manager"], permissionCodes: ["report.dashboard.view"], }, }); const response = await app.inject({ method: "GET", url: "/api/exports/presence-history?format=csv", }); expect(response.statusCode).toBe(403); await app.close(); }); }); describe("presence export calendar boundaries", () => { it.each(["dateFrom", "dateTo"])("rejects overflowing %s without export audit or employee reads", async (field) => { const { app, employeeFindMany, auditCreate } = await buildApp(); try { const response = await app.inject({ method: "GET", url: `/api/exports/presence-history?${field}=2026-04-31` }); expect(response.statusCode).toBe(400); expect(employeeFindMany).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); }); describe("presence history range reasons", () => { it.each([ ["2024-03-01", "2024-02-29", "presence_history.date_range_invalid", { dateTo: "must_be_after_or_equal_dateFrom" }], ["2024-01-01", "2024-02-01", "presence_history.date_range_too_large", { dateRange: "too_large" }], ])("explains %s..%s before employee queries", async (from, to, reasonCode, details) => { const { app, employeeFindMany, auditCreate } = await buildApp(); try { const response = await app.inject({ method: "GET", url: `/api/exports/presence-history?dateFrom=${from}&dateTo=${to}` }); expect(response.statusCode).toBe(400); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { ...details, reasonCode } }); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expect(employeeFindMany).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("keeps exactly 31 inclusive days valid", async () => { const { app, employeeFindMany } = await buildApp(); try { const response = await app.inject({ method: "GET", url: "/api/exports/presence-history?dateFrom=2024-01-01&dateTo=2024-01-31" }); expect(response.statusCode).toBe(200); expect(employeeFindMany).toHaveBeenCalled(); } finally { await app.close(); } }); });