const Fastify = require("fastify"); const presenceRoutes = require("../../../src/routes/presence"); const { AppError } = require("../../../src/utils/AppError"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; 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-28T00:00:00.000Z"), status: "office", time_range: "full_day", note: null, source: "manual", updated_at: new Date("2026-03-28T08:00:00.000Z"), ...overrides, }; } function buildLeaveRow(overrides = {}) { return { id: "leave-1", owner_id: EMPLOYEE_ID, start_date: new Date("2026-03-27T00:00:00.000Z"), end_date: new Date("2026-03-27T00:00:00.000Z"), start_session: "am", end_session: "pm", reason: "请假", ...overrides, }; } async function buildApp(options = {}) { const app = Fastify(); 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 instanceof Date) { return row.date.getTime() === where.date.getTime(); } 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; }), ); app.decorate("authenticate", async (request) => { request.employee = { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: [], }; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: [], }; if (!request.employee.permissionCodes.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("config", { appTimezone: "Asia/Shanghai", presenceBoardEnabled: true, }); app.decorate("prisma", { employee: { findMany: employeeFindMany, }, employeePresence: { findMany: employeePresenceFindMany, }, leaveRequest: { findMany: leaveFindMany, }, travelRequest: { findMany: travelFindMany, }, featureFlag: { findUnique: vi.fn().mockResolvedValue({ id: "flag-presence", flag_key: "presence_board_v1", override_mode: "force_on", rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-03-01T00:00:00.000Z"), created_by: "system", updated_at: new Date("2026-03-01T00:00:00.000Z"), updated_by: "system", }), }, }); await app.register(presenceRoutes, { prefix: "/api" }); return { app, employeeFindMany }; } describe("presence history routes", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-28T10:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); it("defaults me history to the last 7 days", async () => { const { app } = await buildApp({ presenceRows: [ buildPresenceRow({ date: new Date("2026-03-27T00:00:00.000Z"), status: "remote", note: "历史记录", }), ], }); const response = await app.inject({ method: "GET", url: "/api/presence/me/history", }); expect(response.statusCode).toBe(200); const payload = response.json().data; expect(payload.dateFrom).toBe("2026-03-22"); expect(payload.dateTo).toBe("2026-03-28"); expect(payload.items).toHaveLength(7); expect(payload.items[1]).toMatchObject({ date: "2026-03-27", presence: expect.objectContaining({ status: "remote", }), }); await app.close(); }); it("rejects history ranges longer than 31 days", async () => { const { app, employeeFindMany } = await buildApp(); const response = await app.inject({ method: "GET", url: "/api/presence/team/history?dateFrom=2026-02-01&dateTo=2026-03-28", }); expect(response.statusCode).toBe(400); expect(employeeFindMany).not.toHaveBeenCalled(); await app.close(); }); it("filters team history by employee department and unspecified status", async () => { const employeeId = "22222222-2222-4222-8222-222222222222"; const otherEmployee = buildEmployeeRow({ id: employeeId, employee_no: "E002", name: "Bob", }); const archivedEmployee = buildEmployeeRow({ id: "33333333-3333-4333-8333-333333333333", employee_no: "E003", name: "Carol", status: "archived", }); const { app, employeeFindMany } = await buildApp({ employees: [otherEmployee, archivedEmployee], presenceRows: [ buildPresenceRow({ employee_id: employeeId, date: new Date("2026-03-28T00:00:00.000Z"), status: "office", }), ], }); const response = await app.inject({ method: "GET", url: `/api/presence/team/history?dateFrom=2026-03-27&dateTo=2026-03-28&departmentId=${DEPARTMENT_ID}&employeeId=${employeeId}&status=unspecified`, }); expect(response.statusCode).toBe(200); const payload = response.json().data; expect(payload.items).toEqual([ { date: "2026-03-27", employee: expect.objectContaining({ id: employeeId, employeeNo: "E002", }), presence: null, }, ]); expect(payload.summary).toEqual({ office: 0, remote: 0, out: 0, travel: 0, leave: 0, unspecified: 1, total: 1, }); expect(employeeFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: "active", department_id: DEPARTMENT_ID, id: employeeId, }), }), ); await app.close(); }); it("prefers leave overlays over manual records in team history", async () => { const { app } = await buildApp({ presenceRows: [ buildPresenceRow({ date: new Date("2026-03-27T00:00:00.000Z"), status: "office", note: "原始手工状态", }), ], leaveRows: [buildLeaveRow()], }); const response = await app.inject({ method: "GET", url: "/api/presence/team/history?dateFrom=2026-03-27&dateTo=2026-03-27", }); expect(response.statusCode).toBe(200); const payload = response.json().data; expect(payload.items).toEqual([ { date: "2026-03-27", employee: expect.objectContaining({ id: EMPLOYEE_ID, employeeNo: "E001", }), presence: expect.objectContaining({ status: "leave", source: "leave_sync", }), }, ]); await app.close(); }); });