文件
hyb-oa/server/tests/unit/routes/portal.test.js
T
wangsli ed3814fccd
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
fix: 合并后清理重复测试与修复 CEO/财务兜底脚本注入 bug
- apply-ceo-finance-fallback.js: execute() 原先在传入 mock pool 时仍强制要求
  DATABASE_URL 环境变量,导致单测无法注入 mock pool 验证事务逻辑。改为仅在
  未传入 pool 时才检查 DATABASE_URL。
- portal.test.js: 删除本次合并前遗留的重复用例(期望旧的
  portal.feature_disabled reasonCode),候选分支自带的等价用例更完整
  (校验 reasonCode 以及全部零写入断言)。
- todos.test.js: 删除本次合并前遗留的文件,候选分支的
  todos.errors.test.js 覆盖等价且更完整。
2026-09-26 08:23:07 +08:00

577 行
19 KiB
JavaScript

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", () => {
it("returns the registered disabled reason without changing the portal gate", async () => {
featureFlagState.portalHome = false;
const { app, prisma } = await buildApp();
try {
const response = await app.inject({ method: "GET", url: "/api/portal/home", headers: { authorization: "Bearer synthetic" } });
expect(response.statusCode).toBe(409);
expect(response.json().error.code).toBe("VALID_001");
expect(response.json().error.details).toMatchObject({ reasonCode: "portal.disabled", featureFlag: "portal_home_v1_disabled" });
const businessCalls = Object.entries(prisma).filter(([name]) => name !== "featureFlag").flatMap(([, model]) => Object.values(model ?? {})).filter(vi.isMockFunction);
expect(businessCalls.every((fn) => fn.mock.calls.length === 0)).toBe(true);
} finally { await app.close(); }
});
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();
});
});