const Fastify = require("fastify"); const attendanceRoutes = require("../../../src/routes/attendance"); const { AppError } = require("../../../src/utils/AppError"); const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111"; const MANAGER_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const originalFetch = global.fetch; function buildEmployee(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "E001", name: "员工一", title: "工程师", status: "active", department_id: DEPARTMENT_ID, direct_manager_id: null, department: { id: DEPARTMENT_ID, name: "研发部" }, wecom_identity_binding: { external_user_id: "wecom-user-1" }, ...overrides, }; } function buildFlag(flagKey, overrideMode = "force_on") { return { id: `flag-${flagKey}`, flag_key: flagKey, override_mode: overrideMode, rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-05-01T00:00:00.000Z"), updated_at: new Date("2026-05-01T00:00:00.000Z"), }; } function buildJsonResponse(payload, overrides = {}) { return { ok: overrides.ok ?? true, status: overrides.status ?? 200, json: async () => payload, }; } function buildApp(options = {}) { const app = Fastify(); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ error: { code: error.code, message: error.message, details: error.details ?? {} }, }); } return reply.status(error.statusCode ?? 500).send({ error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} }, }); }); const actor = options.actor ?? { id: EMPLOYEE_ID, department_id: DEPARTMENT_ID, permissionCodes: ["attendance.view_own", "attendance.create", "attendance.submit", "attendance.sync", "attendance.view_all"], roleCodes: ["hr"], }; const employees = options.employees ?? [buildEmployee()]; const syncRuns = []; const checkins = [...(options.checkins ?? [])]; const corrections = [...(options.corrections ?? [])]; const leaves = [...(options.leaves ?? [])]; const featureFlagFindUnique = vi.fn(async ({ where }) => { if (where.flag_key === "attendance_unified_v1") { return buildFlag("attendance_unified_v1", options.unifiedOverrideMode ?? "force_on"); } if (where.flag_key === "attendance_v1") { return buildFlag("attendance_v1", "force_on"); } return buildFlag(where.flag_key, "force_on"); }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("assertEntityPermission", (employee, entityType, action) => { const permission = `${entityType === "attendance_correction" ? "attendance" : entityType}.${action}`; if (!(employee.permissionCodes ?? []).includes(permission) && !(employee.permissionCodes ?? []).includes("*")) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } }); } }); app.decorate("requirePermission", (permission) => async (request) => { await app.authenticate(request); if (!(actor.permissionCodes ?? []).includes(permission)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("config", { appTimezone: "Asia/Shanghai", attendanceEnabled: true, attendanceUnifiedEnabled: true, wecomCheckinSyncEnabled: false, wecomCheckinSecret: "", leaveHolidayCalendarEnabled: false, ...options.config, }); app.decorate("prisma", { featureFlag: { findUnique: featureFlagFindUnique }, employee: { findUnique: vi.fn(async ({ where }) => employees.find((item) => item.id === where.id) ?? null), findMany: vi.fn(async ({ where }) => employees.filter((item) => { if (where?.status && item.status !== where.status) return false; if (where?.id?.in && !where.id.in.includes(item.id)) return false; if (where?.OR?.length) { return where.OR.some((condition) => { if (condition.id && item.id === condition.id) return true; if (condition.direct_manager_id && item.direct_manager_id === condition.direct_manager_id) return true; return false; }); } return true; }), ), }, attendanceRecord: { findFirst: vi.fn(), findMany: vi.fn(async () => []), create: vi.fn(), update: vi.fn(), }, attendancePolicy: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), }, legalEntity: { findMany: vi.fn(async () => []), }, travelRequest: { findMany: vi.fn(async () => []), }, leaveRequest: { findMany: vi.fn(async () => leaves), }, wecomCheckinRecord: { findMany: vi.fn(async () => checkins), createMany: vi.fn(async ({ data }) => { checkins.push(...data); return { count: data.length }; }), }, attendanceCorrection: { findMany: vi.fn(async () => corrections), }, wecomCheckinSyncRun: { create: vi.fn(async ({ data }) => { const row = { id: `sync-${syncRuns.length + 1}`, created_at: new Date("2026-05-17T02:00:00.000Z"), updated_at: new Date("2026-05-17T02:00:00.000Z"), trigger: buildEmployee(), ...data, }; syncRuns.push(row); return row; }), update: vi.fn(async ({ where, data }) => { const index = syncRuns.findIndex((item) => item.id === where.id); syncRuns[index] = { ...syncRuns[index], ...data, updated_at: new Date("2026-05-17T02:01:00.000Z"), trigger: buildEmployee(), }; return syncRuns[index]; }), findMany: vi.fn(async () => syncRuns), count: vi.fn(async () => syncRuns.length), }, auditLog: { create: vi.fn(async () => ({})), }, }); app.register(attendanceRoutes, { prefix: "/api" }); return { app, prisma: app.prisma, syncRuns }; } describe("attendance unified routes", () => { let currentApp = null; afterEach(async () => { global.fetch = originalFetch; if (currentApp) { await currentApp.close(); currentApp = null; } }); it("keeps historical OA clock-in API disabled even when legacy flag record is force_on", async () => { const { app } = buildApp(); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/clock-in", payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toMatchObject({ featureFlag: "attendance_v1_disabled", reason: "env_hard_off", }); }); it("returns unified monthly summary with WeCom checkin and OA correction explanation", async () => { const { app } = buildApp({ checkins: [ { id: "checkin-1", employee_id: EMPLOYEE_ID, checkin_time: new Date("2026-05-11T01:00:00.000Z"), checkin_date: new Date("2026-05-11T00:00:00.000Z"), checkin_type: "上班打卡", exception_type: null, source_type: "app", result: null, }, ], corrections: [ { id: "correction-1", owner_id: EMPLOYEE_ID, kind: "missed_punch", status: "approved", work_date: new Date("2026-05-12T00:00:00.000Z"), missed_punch_at: new Date("2026-05-12T01:00:00.000Z"), start_at: null, end_at: null, reason: "企微缺卡,已说明", location_summary: null, compensation_policy: null, created_at: new Date("2026-05-12T02:00:00.000Z"), updated_at: new Date("2026-05-12T02:00:00.000Z"), owner: buildEmployee(), }, ], }); currentApp = app; const response = await app.inject({ method: "GET", url: "/api/attendance/monthly?month=2026-05", }); expect(response.statusCode).toBe(200); const payload = response.json().data; const employee = payload.employees[0]; const checkinDay = employee.days.find((item) => item.date === "2026-05-11"); const correctionDay = employee.days.find((item) => item.date === "2026-05-12"); expect(checkinDay).toMatchObject({ status: "normal", firstCheckinAt: "2026-05-11T01:00:00.000Z" }); expect(correctionDay).toMatchObject({ status: "corrected", explanation: "补卡" }); expect(correctionDay.corrections[0]).toMatchObject({ reason: "企微缺卡,已说明" }); }); it("scopes manager monthly summary to self and direct reports", async () => { const manager = buildEmployee({ id: MANAGER_ID, employee_no: "M001", name: "经理", direct_manager_id: null, wecom_identity_binding: { external_user_id: "manager-user" }, }); const directReport = buildEmployee({ direct_manager_id: MANAGER_ID }); const outsider = buildEmployee({ id: "33333333-3333-4333-8333-333333333333", employee_no: "E999", name: "其他员工", direct_manager_id: "44444444-4444-4444-8444-444444444444", wecom_identity_binding: { external_user_id: "outsider-user" }, }); const { app } = buildApp({ actor: { id: MANAGER_ID, department_id: DEPARTMENT_ID, permissionCodes: ["attendance.view_own", "attendance.view_all"], roleCodes: ["manager"], }, employees: [manager, directReport, outsider], }); currentApp = app; const response = await app.inject({ method: "GET", url: "/api/attendance/monthly?month=2026-05", }); expect(response.statusCode).toBe(200); const names = response.json().data.employees.map((item) => item.employee.name); expect(names).toEqual(expect.arrayContaining(["经理", "员工一"])); expect(names).not.toContain("其他员工"); }); it("records WeCom sync as skipped when checkin secret or API permission is not configured", async () => { const { app, prisma } = buildApp(); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/wecom/sync", payload: { startDate: "2026-05-11", endDate: "2026-05-12", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ skipped: true, code: "wecom_checkin_sync_disabled", employeeCount: 1, boundUserCount: 1, recordCount: 0, }); expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "skipped", error_code: "wecom_checkin_sync_disabled", }), }), ); }); it("records WeCom sync as skipped when no authorized app secret is configured", async () => { const { app, prisma } = buildApp({ config: { wecomCheckinSyncEnabled: true, wecomCorpId: "corp-id", wecomAgentId: "1000001", wecomCheckinSecret: "", wecomAgentSecret: "", }, }); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/wecom/sync", payload: { startDate: "2026-05-11", endDate: "2026-05-12", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ skipped: true, code: "wecom_checkin_secret_missing", employeeCount: 1, boundUserCount: 1, recordCount: 0, }); expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "skipped", error_code: "wecom_checkin_secret_missing", }), }), ); }); it("uses the authorized workbench app secret fallback for WeCom checkin sync", async () => { global.fetch = vi .fn() .mockResolvedValueOnce(buildJsonResponse({ errcode: 0, access_token: "token-from-workbench" })) .mockResolvedValueOnce( buildJsonResponse({ errcode: 0, checkindata: [ { userid: "wecom-user-1", checkin_time: 1778461200, checkin_type: "上班打卡", exception_type: "", source_type: "app", result: "正常", }, ], }), ); const { app, prisma } = buildApp({ config: { wecomCheckinSyncEnabled: true, wecomCorpId: "corp-id", wecomAgentId: "1000001", wecomCheckinSecret: "", wecomAgentSecret: "workbench-secret", }, }); currentApp = app; const response = await app.inject({ method: "POST", url: "/api/attendance/wecom/sync", payload: { startDate: "2026-05-11", endDate: "2026-05-12", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ skipped: false, code: "wecom_checkin_fetched", employeeCount: 1, boundUserCount: 1, recordCount: 1, }); expect(String(global.fetch.mock.calls[0][0])).toContain("corpsecret=workbench-secret"); expect(String(global.fetch.mock.calls[1][0])).toContain("/cgi-bin/checkin/getcheckindata"); expect(prisma.wecomCheckinRecord.createMany).toHaveBeenCalledWith( expect.objectContaining({ data: [ expect.objectContaining({ employee_id: EMPLOYEE_ID, external_user_id: "wecom-user-1", checkin_type: "上班打卡", result: "正常", }), ], }), ); expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: "success", record_count: 1, error_code: null, }), }), ); }); });