const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const featureFlagState = vi.hoisted(() => ({ portalHome: true, announcements: false, presenceBoard: false, weeklyReport: false, employeeLifecycle: false, todoBoard: false, paymentBatch: false, projectDomain: false, projectCommunicationLog: false, projectClosureChecklist: false, })); vi.mock("../../../src/services/featureFlags", () => ({ FEATURE_FLAG_KEYS: { portalHome: "portal_home_v1", announcements: "announcements_v1", presenceBoard: "presence_board_v1", weeklyReport: "weekly_report_v1", employeeLifecycle: "employee_lifecycle_v1", todoBoard: "todo_board_v1", paymentBatch: "payment_batch_v1", projectDomain: "project_domain_v1", projectCommunicationLog: "project_communication_log_v1", projectClosureChecklist: "project_closure_checklist_v1", }, evaluateFeatureFlag: vi.fn(async ({ flagKey }) => { if (flagKey === "portal_home_v1") { return { enabled: featureFlagState.portalHome, reason: featureFlagState.portalHome ? "enabled" : "force_off" }; } if (flagKey === "announcements_v1") return { enabled: featureFlagState.announcements, reason: "mock" }; if (flagKey === "presence_board_v1") return { enabled: featureFlagState.presenceBoard, reason: "mock" }; if (flagKey === "weekly_report_v1") return { enabled: featureFlagState.weeklyReport, reason: "mock" }; if (flagKey === "employee_lifecycle_v1") return { enabled: featureFlagState.employeeLifecycle, reason: "mock" }; if (flagKey === "todo_board_v1") return { enabled: featureFlagState.todoBoard, reason: "mock" }; if (flagKey === "payment_batch_v1") return { enabled: featureFlagState.paymentBatch, reason: "mock" }; if (flagKey === "project_domain_v1") return { enabled: featureFlagState.projectDomain, reason: "mock" }; if (flagKey === "project_communication_log_v1") return { enabled: featureFlagState.projectCommunicationLog, reason: "mock" }; if (flagKey === "project_closure_checklist_v1") return { enabled: featureFlagState.projectClosureChecklist, reason: "mock" }; return { enabled: false, reason: "mock" }; }), })); vi.mock("../../../src/services/workspaceSettings", () => ({ getWorkspaceSetting: vi.fn(async () => ({ id: "workspace-1", persisted: true, profile: "small_team_internal", })), })); const resolvePresenceForEmployee = vi.fn(); const buildTeamPresenceSummary = vi.fn(async () => ({ office: 0, remote: 0, out: 0, travel: 0, leave: 0, unspecified: 0, total: 0, })); vi.mock("../../../src/services/presenceHistory", () => ({ resolvePresenceForEmployee, buildTeamPresenceSummary, })); const portalRoutes = require("../../../src/routes/portal"); function buildAttendanceRow(overrides = {}) { return { id: "attendance-1", employee_id: "employee-1", clock_in_at: new Date("2026-04-12T01:15:00.000Z"), clock_out_at: null, employee: { name: "张三" }, ...overrides, }; } function buildWorkflowRow(overrides = {}) { return { id: "history-1", action: "start", created_at: new Date("2026-04-12T02:00:00.000Z"), actor: { name: "李四" }, workflow_instance: { entity_type: "reimbursement", entity_id: "reimbursement-1", }, ...overrides, }; } function buildAuditRow(overrides = {}) { return { id: "audit-1", action: "ASSET_ASSIGNED", target_id: "asset-1", extra: { assetNo: "AS-001" }, created_at: new Date("2026-04-12T03:00:00.000Z"), actor: { name: "王五" }, ...overrides, }; } function buildEmployeeRow(overrides = {}) { return { id: "employee-1", employee_no: "E001", name: "张三", avatar_path: "avatars/zs.png", legal_entity_id: "legal-1", status: "active", ...overrides, }; } function buildLeaveRow(overrides = {}) { return { owner_id: "employee-2", start_date: new Date("2026-04-12T00:00:00.000Z"), end_date: new Date("2026-04-12T00:00:00.000Z"), reason: "休假", status: "approved", leave_type: { name: "年假" }, ...overrides, }; } function buildPresenceRow(overrides = {}) { return { id: "presence-1", employee_id: "employee-1", date: new Date("2026-04-12T00:00:00.000Z"), status: "office", time_range: "full_day", note: null, source: "manual", updated_at: new Date("2026-04-12T00:00:00.000Z"), ...overrides, }; } async function buildApp(options = {}) { const workflowHistoryRows = options.workflowHistoryRows ?? []; const auditLogRows = options.auditLogRows ?? []; const attendanceRows = options.attendanceRows ?? []; const employees = options.employees ?? [buildEmployeeRow()]; const leaveRows = options.leaveRows ?? []; const employeePresenceRows = options.employeePresenceRows ?? Object.entries(options.presenceByEmployeeId ?? {}) .filter(([, value]) => value && value.status && value.status !== "leave" && value.status !== "travel") .map(([employeeId, value], index) => buildPresenceRow({ id: `presence-${index + 1}`, employee_id: employeeId, status: value.status, }), ); const travelRows = options.travelRows ?? []; const permissionCodes = options.permissionCodes ?? ["expense.view_own"]; const roleCodes = options.roleCodes ?? []; const employeeId = options.employeeId ?? "employee-1"; resolvePresenceForEmployee.mockImplementation(async (_prisma, targetEmployeeId) => { return options.presenceByEmployeeId?.[targetEmployeeId] ?? null; }); const prisma = { workflowTask: { count: vi.fn(async () => 0), }, reimbursementForm: { count: vi.fn(async () => 2), findMany: vi.fn(async () => []), }, leaveRequest: { count: vi.fn(async () => 1), findMany: vi.fn(async ({ where }) => { if (!where?.owner_id?.in) { return []; } return leaveRows.filter((row) => where.owner_id.in.includes(row.owner_id)); }), }, assetCard: { count: vi.fn(async () => 1), }, paymentBatch: { count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0), findMany: vi.fn(async () => []), }, paymentBatchItem: { count: vi.fn(async () => options.failedFinanceItemCount ?? 0), }, entityRiskFlag: { count: vi.fn(async () => options.financeRiskCount ?? 0), }, employee: { count: vi.fn(async () => employees.length), findMany: vi.fn(async () => employees), }, employeePresence: { findMany: vi.fn(async ({ where }) => employeePresenceRows.filter((row) => { if (where?.employee_id?.in && !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; }), ), }, authSession: { findMany: vi.fn(async () => []), }, featureFlag: { findUnique: vi.fn(async ({ where }) => { const enabledByFlagKey = { portal_home_v1: featureFlagState.portalHome, announcements_v1: featureFlagState.announcements, presence_board_v1: featureFlagState.presenceBoard, weekly_report_v1: featureFlagState.weeklyReport, employee_lifecycle_v1: featureFlagState.employeeLifecycle, todo_board_v1: featureFlagState.todoBoard, payment_batch_v1: featureFlagState.paymentBatch, project_domain_v1: featureFlagState.projectDomain, project_communication_log_v1: featureFlagState.projectCommunicationLog, project_closure_checklist_v1: featureFlagState.projectClosureChecklist, }; if (!Object.prototype.hasOwnProperty.call(enabledByFlagKey, where.flag_key)) { return null; } return { id: `flag-${where.flag_key}`, flag_key: where.flag_key, override_mode: enabledByFlagKey[where.flag_key] ? "force_on" : "force_off", rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-04-01T00:00:00.000Z"), created_by: null, updated_at: new Date("2026-04-01T00:00:00.000Z"), updated_by: null, }; }), }, workspaceSetting: { findFirst: vi.fn(async () => ({ id: "workspace-1", profile: "small_team_internal", org_mode: "single_company", primary_entry_mode: "password", notification_strategy: "in_app", device_priority: "desktop", module_visibility_json: {}, created_at: new Date("2026-04-01T00:00:00.000Z"), updated_at: new Date("2026-04-01T00:00:00.000Z"), updated_by: null, })), }, workflowHistory: { findMany: vi.fn(async () => workflowHistoryRows), }, auditLog: { findMany: vi.fn(async () => auditLogRows), }, attendanceRecord: { findMany: vi.fn(async ({ where }) => { if (where?.employee_id?.in) { return attendanceRows.filter((row) => where.employee_id.in.includes(row.employee_id)); } return attendanceRows; }), }, travelRequest: { findMany: vi.fn(async ({ where }) => { if (!where?.applicant_employee_id?.in) { return []; } return travelRows.filter((row) => where.applicant_employee_id.in.includes(row.applicant_employee_id)); }), }, }; const app = Fastify(); prisma.$executeRawUnsafe = vi.fn(async () => 0); prisma.$transaction = vi.fn(async (work) => work(prisma)); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai", portalHomeEnabled: featureFlagState.portalHome, announcementsEnabled: featureFlagState.announcements, presenceBoardEnabled: featureFlagState.presenceBoard, weeklyReportEnabled: featureFlagState.weeklyReport, employeeLifecycleEnabled: featureFlagState.employeeLifecycle, todoBoardEnabled: featureFlagState.todoBoard, paymentBatchEnabled: featureFlagState.paymentBatch, projectDomainEnabled: featureFlagState.projectDomain, projectCommunicationLogEnabled: featureFlagState.projectCommunicationLog, projectClosureChecklistEnabled: featureFlagState.projectClosureChecklist, }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: employeeId, name: "测试用户", department_id: "department-1", permissionCodes, roleCodes, }; }); app.decorate( "hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode), ); 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({ requestId: request.id, error: { code: "SYS_001", message: "服务器内部错误", details: {} }, }); }); await app.register(portalRoutes, { prefix: "/api" }); return { app, prisma }; } describe("portal routes", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-12T02:00:00.000Z")); featureFlagState.portalHome = true; featureFlagState.announcements = false; featureFlagState.presenceBoard = false; featureFlagState.weeklyReport = false; featureFlagState.employeeLifecycle = false; featureFlagState.todoBoard = false; featureFlagState.paymentBatch = false; featureFlagState.projectDomain = false; featureFlagState.projectCommunicationLog = false; featureFlagState.projectClosureChecklist = false; resolvePresenceForEmployee.mockReset(); buildTeamPresenceSummary.mockClear(); }); afterEach(() => { vi.useRealTimers(); }); it("extends /portal/home with normalized recentActivities and teamStatusBoard", async () => { const recentWorkflowRows = Array.from({ length: 8 }, (_, index) => buildWorkflowRow({ id: `history-${index + 1}`, action: index % 2 === 0 ? "approve" : "start", created_at: new Date(Date.UTC(2026, 3, 12, 10, index, 0)), actor: { name: `流程用户${index + 1}` }, workflow_instance: { entity_type: index % 3 === 0 ? "leave" : "reimbursement", entity_id: `entity-${index + 1}`, }, }), ); const recentAuditRows = Array.from({ length: 4 }, (_, index) => buildAuditRow({ id: `audit-${index + 1}`, action: index === 0 ? "ATTENDANCE_ADMIN_BACKFILLED" : index % 2 === 0 ? "ASSET_ASSIGNED" : "ASSET_RETURNED", created_at: new Date(Date.UTC(2026, 3, 12, 11, index, 0)), actor: { name: `审计用户${index + 1}` }, extra: { assetNo: `AS-${index + 1}` }, }), ); const recentAttendanceRows = Array.from({ length: 4 }, (_, index) => buildAttendanceRow({ id: `attendance-${index + 1}`, employee_id: `employee-${index + 1}`, employee: { name: `打卡用户${index + 1}` }, clock_in_at: new Date(Date.UTC(2026, 3, 12, 12, index, 0)), clock_out_at: new Date(Date.UTC(2026, 3, 12, 13, index, 0)), }), ); const employees = [ buildEmployeeRow({ id: "employee-1", name: "张三" }), buildEmployeeRow({ id: "employee-2", name: "李四", avatar_path: null }), ]; const { app } = await buildApp({ workflowHistoryRows: recentWorkflowRows, auditLogRows: recentAuditRows, attendanceRows: recentAttendanceRows, employees, leaveRows: [buildLeaveRow({ owner_id: "employee-2", leave_type: { name: "年假" } })], presenceByEmployeeId: { "employee-1": { status: "office" }, "employee-2": { status: "leave" }, }, }); const response = await app.inject({ method: "GET", url: "/api/portal/home", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); const payload = response.json().data; expect(payload.recentActivities).toHaveLength(15); expect(payload.recentActivities[0]).toMatchObject({ type: "attendance.clock_out", actorName: "打卡用户4", href: "/attendance", }); expect(payload.recentActivities.map((item) => item.createdAt)).toEqual( [...payload.recentActivities.map((item) => item.createdAt)].sort((left, right) => new Date(right).getTime() - new Date(left).getTime()), ); expect(payload.recentActivities.map((item) => item.type)).toContain("reimbursement.submit"); expect(payload.recentActivities.map((item) => item.type)).toContain("leave.approve"); expect(payload.recentActivities.map((item) => item.type)).toContain("asset.assign"); expect(payload.recentActivities.map((item) => item.type)).toContain("attendance.supplement"); expect(payload.teamStatusBoard).toEqual([ { employeeId: "employee-1", name: "张三", avatarUrl: "/api/avatars/employee-1", presenceStatus: "office", attendanceClockIn: "2026-04-12T12:00:00.000Z", attendanceClockOut: "2026-04-12T13:00:00.000Z", onLeave: false, leaveType: null, }, { employeeId: "employee-2", name: "李四", avatarUrl: null, presenceStatus: "leave", attendanceClockIn: "2026-04-12T12:01:00.000Z", attendanceClockOut: "2026-04-12T13:01:00.000Z", onLeave: true, leaveType: "年假", }, ]); await app.close(); }); it("allows /portal/team-status even when portal_home_v1 is disabled", async () => { featureFlagState.portalHome = false; const { app } = await buildApp({ employees: [buildEmployeeRow({ id: "employee-1", name: "张三" })], attendanceRows: [buildAttendanceRow({ employee_id: "employee-1" })], presenceByEmployeeId: { "employee-1": { status: "office" } }, }); const response = await app.inject({ method: "GET", url: "/api/portal/team-status", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual([ { employeeId: "employee-1", name: "张三", avatarUrl: "/api/avatars/employee-1", presenceStatus: "office", attendanceClockIn: "2026-04-12T01:15:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null, }, ]); await app.close(); }); it("returns roleCapabilities and financeSummary only for finance roles", async () => { featureFlagState.paymentBatch = true; const { app } = await buildApp({ roleCodes: ["employee", "finance"], permissionCodes: ["expense.view_own", "payment.batch.manage", "expense.finance.pay", "expense.approve"], pendingFinanceBatchCount: 3, failedFinanceItemCount: 2, }); const response = await app.inject({ method: "GET", url: "/api/portal/home", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); const payload = response.json().data; expect(payload.roleCapabilities).toMatchObject({ canApproveTasks: true, canUseFinance: true, canManageSystem: false, }); expect(payload.financeSummary).toEqual({ pendingBatchCount: 3, failedItemCount: 2, riskCount: 0, paymentBatchEnabled: true, href: "/ap-operations", }); await app.close(); }); it("does not expose financeSummary to admin-only accounts", async () => { featureFlagState.paymentBatch = true; const { app } = await buildApp({ roleCodes: ["admin"], permissionCodes: ["*"], pendingFinanceBatchCount: 3, failedFinanceItemCount: 2, }); const response = await app.inject({ method: "GET", url: "/api/portal/home", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); const payload = response.json().data; expect(payload.roleCapabilities).toMatchObject({ canManageSystem: true, canUseFinance: false, isAdminOnly: true, }); expect(payload.financeSummary).toBeNull(); expect(payload.cards.map((card) => card.key)).not.toContain("pending_approvals"); await app.close(); }); });