const Fastify = require("fastify"); const attendanceRoutes = require("../../../src/routes/attendance"); 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 LEGAL_ENTITY_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; function buildEmployeeRow(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "E001", name: "Alice", title: "员工", department_id: DEPARTMENT_ID, legal_entity_id: LEGAL_ENTITY_ID, status: "active", department: { id: DEPARTMENT_ID, name: "行政部", }, ...overrides, }; } function buildAttendanceRecord(overrides = {}) { return { id: "attendance-1", employee_id: EMPLOYEE_ID, work_date: new Date("2026-04-09T00:00:00.000Z"), clock_in_at: null, clock_out_at: null, work_minutes: null, is_late: false, late_minutes: 0, is_early_leave: false, early_minutes: 0, note: null, source: "web", created_at: new Date("2026-04-09T01:00:00.000Z"), updated_at: new Date("2026-04-09T01:00:00.000Z"), updated_by: ACTOR_ID, ...overrides, }; } function buildPolicy(overrides = {}) { return { id: "policy-1", work_start_time: "09:00", work_end_time: "18:00", lunch_start_time: "12:00", lunch_end_time: "13:00", late_tolerance_minutes: 0, enabled: true, created_at: new Date("2026-04-01T00:00:00.000Z"), updated_at: new Date("2026-04-01T00:00:00.000Z"), updated_by: null, ...overrides, }; } function buildLeaveRow(overrides = {}) { return { owner_id: EMPLOYEE_ID, start_date: new Date("2026-04-09T00:00:00.000Z"), end_date: new Date("2026-04-09T00:00:00.000Z"), reason: "请假", status: "approved", ...overrides, }; } function buildTravelRow(overrides = {}) { return { applicant_employee_id: EMPLOYEE_ID, start_date: new Date("2026-04-09T00:00:00.000Z"), end_date: new Date("2026-04-09T00:00:00.000Z"), purpose: "出差", status: "approved", ...overrides, }; } function buildLegalEntityRow(overrides = {}) { return { id: LEGAL_ENTITY_ID, default_holiday_calendar: { id: "calendar-1", archived_at: null, days: [], }, ...overrides, }; } function sameDate(left, right) { return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10); } function projectEmployeeForFindUnique(employee, select) { if (!employee) { return null; } if (!select) { return employee; } const projected = {}; for (const [key, value] of Object.entries(select)) { if (!value) { continue; } projected[key] = employee[key]; } return projected; } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: [], }; const employees = options.employees ?? [buildEmployeeRow()]; const attendanceRecords = [...(options.attendanceRecords ?? [])]; const attendancePolicies = [...(options.attendancePolicies ?? [buildPolicy()])]; const leaveRows = options.leaveRows ?? []; const travelRows = options.travelRows ?? []; const legalEntities = options.legalEntities ?? [buildLegalEntityRow()]; const employeeFindUnique = vi.fn(async ({ where, select }) => { const employee = employees.find((item) => item.id === where.id); return projectEmployeeForFindUnique(employee, select); }); const employeeFindMany = vi.fn(async ({ where, select }) => { const rows = employees .filter((item) => !where?.status || item.status === where.status) .filter((item) => !where?.department_id || item.department_id === where.department_id) .filter((item) => !where?.id || item.id === where.id) .map((item) => { if (!select) { return item; } return { id: item.id, employee_no: item.employee_no, name: item.name, title: item.title, department_id: item.department_id, legal_entity_id: item.legal_entity_id, department: item.department, }; }); return rows; }); const attendanceRecordFindFirst = vi.fn(async ({ where }) => attendanceRecords.find((row) => { if (where?.employee_id && row.employee_id !== where.employee_id) return false; if (where?.work_date instanceof Date) { return sameDate(row.work_date, where.work_date); } return true; }) ?? null, ); const attendanceRecordFindMany = vi.fn(async ({ where }) => attendanceRecords.filter((row) => { if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) return false; if (where?.employee_id && typeof where.employee_id === "string" && row.employee_id !== where.employee_id) return false; if (where?.work_date instanceof Date) { return sameDate(row.work_date, where.work_date); } if (where?.work_date?.gte && row.work_date < where.work_date.gte) return false; if (where?.work_date?.lte && row.work_date > where.work_date.lte) return false; return true; }), ); const attendanceRecordCreate = vi.fn(async ({ data }) => { const created = buildAttendanceRecord({ id: `attendance-${attendanceRecords.length + 1}`, employee_id: data.employee_id, work_date: data.work_date, clock_in_at: data.clock_in_at ?? null, clock_out_at: data.clock_out_at ?? null, work_minutes: data.work_minutes ?? null, is_late: data.is_late ?? false, late_minutes: data.late_minutes ?? 0, is_early_leave: data.is_early_leave ?? false, early_minutes: data.early_minutes ?? 0, note: data.note ?? null, source: data.source ?? "web", updated_by: data.updated_by ?? null, updated_at: new Date(), }); attendanceRecords.push(created); return created; }); const attendanceRecordUpdate = vi.fn(async ({ where, data }) => { const index = attendanceRecords.findIndex((row) => row.id === where.id); if (index < 0) { throw new Error("record_not_found"); } attendanceRecords[index] = { ...attendanceRecords[index], ...data, updated_at: new Date(), }; return attendanceRecords[index]; }); const attendancePolicyFindFirst = vi.fn(async () => attendancePolicies[0] ?? null); const attendancePolicyUpdate = vi.fn(async ({ where, data }) => { const index = attendancePolicies.findIndex((item) => item.id === where.id); if (index < 0) { throw new Error("policy_not_found"); } attendancePolicies[index] = { ...attendancePolicies[index], ...data, updated_at: new Date(), }; return attendancePolicies[index]; }); const attendancePolicyCreate = vi.fn(async ({ data }) => { const created = buildPolicy({ id: `policy-${attendancePolicies.length + 1}`, ...data, created_at: new Date(), updated_at: new Date(), }); attendancePolicies.push(created); return created; }); const leaveFindMany = vi.fn(async ({ where }) => leaveRows.filter((row) => { if (where?.owner_id?.in && !where.owner_id.in.includes(row.owner_id)) return false; if (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 && !where.applicant_employee_id.in.includes(row.applicant_employee_id)) return false; if (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 legalEntityFindMany = vi.fn(async ({ where }) => legalEntities.filter((item) => where?.id?.in?.includes(item.id)), ); const auditCreate = vi.fn().mockResolvedValue({}); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; if (!(actor.permissionCodes ?? []).includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("config", { appTimezone: "Asia/Shanghai", attendanceEnabled: true, leaveHolidayCalendarEnabled: false, }); app.decorate("prisma", { employee: { findUnique: employeeFindUnique, findMany: employeeFindMany, }, attendanceRecord: { findFirst: attendanceRecordFindFirst, findMany: attendanceRecordFindMany, create: attendanceRecordCreate, update: attendanceRecordUpdate, }, attendancePolicy: { findFirst: attendancePolicyFindFirst, update: attendancePolicyUpdate, create: attendancePolicyCreate, }, leaveRequest: { findMany: leaveFindMany, }, travelRequest: { findMany: travelFindMany, }, legalEntity: { findMany: legalEntityFindMany, }, auditLog: { create: auditCreate, }, featureFlag: { findUnique: vi.fn().mockResolvedValue({ id: "flag-attendance", flag_key: "attendance_v1", override_mode: "force_on", rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-04-01T00:00:00.000Z"), created_by: "system", updated_at: new Date("2026-04-01T00:00:00.000Z"), updated_by: "system", }), }, }); await app.register(attendanceRoutes, { prefix: "/api" }); return { app, auditCreate, attendancePolicies, attendanceRecords, employeeFindMany, attendanceRecordCreate, attendanceRecordUpdate, }; } function countWeekdays(year, month) { let count = 0; const date = new Date(Date.UTC(year, month - 1, 1)); while (date.getUTCMonth() === month - 1) { const day = date.getUTCDay(); if (day !== 0 && day !== 6) { count += 1; } date.setUTCDate(date.getUTCDate() + 1); } return count; } describe("attendance routes", () => { let currentApp = null; beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); }); afterEach(async () => { vi.useRealTimers(); if (currentApp) { await currentApp.close(); currentApp = null; } }); it("keeps clock-in idempotent and does not overwrite the original timestamp", async () => { vi.setSystemTime(new Date("2026-04-09T03:00:00.000Z")); const originalClockIn = new Date("2026-04-09T01:02:00.000Z"); const { app, attendanceRecordCreate, attendanceRecordUpdate } = await buildApp({ attendanceRecords: [ buildAttendanceRecord({ clock_in_at: originalClockIn, updated_at: originalClockIn, }), ], }); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/clock-in", payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.clockInAt).toBe(originalClockIn.toISOString()); expect(attendanceRecordCreate).not.toHaveBeenCalled(); expect(attendanceRecordUpdate).not.toHaveBeenCalled(); }); it("calculates late minutes with tolerance on clock-in", async () => { vi.setSystemTime(new Date("2026-04-09T01:20:00.000Z")); const { app } = await buildApp({ attendancePolicies: [buildPolicy({ late_tolerance_minutes: 15 })], attendanceRecords: [], }); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/clock-in", payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.item).toMatchObject({ isLate: true, lateMinutes: 5, }); }); it("allows repeated clock-out and recalculates work minutes", async () => { vi.setSystemTime(new Date("2026-04-09T10:00:00.000Z")); const { app, attendanceRecords } = await buildApp({ attendanceRecords: [ buildAttendanceRecord({ clock_in_at: new Date("2026-04-09T01:00:00.000Z"), clock_out_at: new Date("2026-04-09T09:00:00.000Z"), work_minutes: 420, }), ], }); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/clock-out", payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.clockOutAt).toBe("2026-04-09T10:00:00.000Z"); expect(response.json().data.item.workMinutes).toBe(480); expect(attendanceRecords[0].clock_out_at.toISOString()).toBe("2026-04-09T10:00:00.000Z"); }); it("calculates work minutes with lunch deduction and early leave on clock-out", async () => { vi.setSystemTime(new Date("2026-04-09T08:30:00.000Z")); const { app } = await buildApp({ attendanceRecords: [ buildAttendanceRecord({ clock_in_at: new Date("2026-04-09T01:00:00.000Z"), }), ], }); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/clock-out", payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.item).toMatchObject({ workMinutes: 390, isEarlyLeave: true, earlyMinutes: 90, }); }); it("marks approved leave day as no-need-clock-in and rejects self clock-in", async () => { vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z")); const { app } = await buildApp({ attendanceRecords: [], leaveRows: [buildLeaveRow()], }); currentApp = app; const todayResponse = await app.inject({ method: "GET", url: "/api/attendance/me/today", }); expect(todayResponse.statusCode).toBe(200); expect(todayResponse.json().data).toMatchObject({ workDate: "2026-04-09", needClockIn: false, exemptionReason: "leave", item: null, }); const clockInResponse = await app.inject({ method: "POST", url: "/api/attendance/clock-in", payload: {}, }); expect(clockInResponse.statusCode).toBe(409); }); it("treats configured legal holidays as no-need-clock-in even when leave holiday flag is off", async () => { vi.setSystemTime(new Date("2026-05-01T01:00:00.000Z")); const { app } = await buildApp({ legalEntities: [ buildLegalEntityRow({ default_holiday_calendar: { id: "calendar-1", archived_at: null, days: [{ date: new Date("2026-05-01T00:00:00.000Z"), day_type: "holiday" }], }, }), ], }); currentApp = app; const response = await app.inject({ method: "GET", url: "/api/attendance/me/today", }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ workDate: "2026-05-01", needClockIn: false, exemptionReason: "holiday", item: null, }); }); it("treats adjusted workdays as working days even on weekends", async () => { vi.setSystemTime(new Date("2026-01-04T01:00:00.000Z")); const { app } = await buildApp({ legalEntities: [ buildLegalEntityRow({ default_holiday_calendar: { id: "calendar-1", archived_at: null, days: [{ date: new Date("2026-01-04T00:00:00.000Z"), day_type: "adjusted_workday" }], }, }), ], }); currentApp = app; const response = await app.inject({ method: "GET", url: "/api/attendance/me/today", }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ workDate: "2026-01-04", needClockIn: true, exemptionReason: null, item: null, }); }); it("builds month summary without counting approved leave as missed", async () => { vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z")); const { app } = await buildApp({ attendanceRecords: [ buildAttendanceRecord({ work_date: new Date("2026-04-01T00:00:00.000Z"), clock_in_at: new Date("2026-04-01T01:00:00.000Z"), clock_out_at: new Date("2026-04-01T10:00:00.000Z"), work_minutes: 480, }), ], leaveRows: [ buildLeaveRow({ start_date: new Date("2026-04-02T00:00:00.000Z"), end_date: new Date("2026-04-02T00:00:00.000Z"), }), ], }); currentApp = app; const response = await app.inject({ method: "GET", url: "/api/attendance/me/month?year=2026&month=4", }); expect(response.statusCode).toBe(200); const payload = response.json().data; const leaveItem = payload.items.find((item) => item.workDate === "2026-04-02"); expect(leaveItem).toMatchObject({ attendanceStatus: "leave", needClockIn: false, }); expect(payload.summary).toMatchObject({ totalWorkDays: countWeekdays(2026, 4) - 1, clockedDays: 1, lateDays: 0, earlyLeaveDays: 0, missedDays: countWeekdays(2026, 4) - 2, totalWorkMinutes: 480, }); }); it("rejects team attendance query without system.users permission", async () => { vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z")); const { app, employeeFindMany } = await buildApp({ actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: [], }, }); currentApp = app; const response = await app.inject({ method: "GET", url: "/api/attendance/team", }); expect(response.statusCode).toBe(403); expect(employeeFindMany).not.toHaveBeenCalled(); }); it("allows system.users to backfill attendance and writes audit log", async () => { vi.setSystemTime(new Date("2026-04-09T09:30:00.000Z")); const { app, auditCreate, attendanceRecords } = await buildApp({ actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.users"], }, employees: [ buildEmployeeRow(), buildEmployeeRow({ id: OTHER_EMPLOYEE_ID, employee_no: "E002", name: "Bob", }), ], }); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/admin/backfill", payload: { employeeId: OTHER_EMPLOYEE_ID, workDate: "2026-04-09", clockInAt: "2026-04-09T01:00:00.000Z", clockOutAt: "2026-04-09T10:00:00.000Z", note: "补卡原因", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item).toMatchObject({ employeeId: OTHER_EMPLOYEE_ID, source: "admin", workMinutes: 480, }); expect(attendanceRecords).toHaveLength(1); expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "ATTENDANCE_ADMIN_BACKFILLED", extra: expect.objectContaining({ employeeId: OTHER_EMPLOYEE_ID, workDate: "2026-04-09", }), }), }), ); }); });