10-05 到期例外清单里已设计好的首批 4 个具体调用,现予落码(原方案见 implement.md §14「首批具体分支与验证要求」): - ap_operations.feature_disabled(409):AP 台账/明细查询功能关闭 - ap_operations.query_invalid(400):AP 台账查询参数校验失败 - todo.feature_disabled(409):待办功能关闭 - portal.feature_disabled(409):工作台首页功能关闭 四处均改为调用 buildBusinessValidationError(reasonCode, details, statusCode), 保留原 HTTP 状态码、featureFlag/reason 等既有 details 字段不变,仅新增精确 reasonCode;不改功能开关判断逻辑、不改权限校验顺序。 - server/src/constants/errorReasons.js + client/src/api/errorReasons.ts: 注册 4 条原因码及中文文案,服务端/客户端逐字一致。 - scripts/qa/error-reason-legacy-baseline.json:移除这 4 条已迁移的旧例外 记录(591 → 587),check-error-reason-contract.mjs 主检查与 self-test 均 PASS,无 new_legacy/stale_exception/unregistered_reason。 - apOperations.permissions.test.js:扩展"功能关闭"断言校验 reasonCode 与 原 details 字段共存;新增"查询参数非法(功能开启)"400 用例,断言零 台账业务查询;新增"合法查询仍 200"用例。 - portal.test.js:新增工作台关闭时 409+reasonCode+零业务查询断言(此前 完全没有覆盖这条路径)。 - todos.test.js:新建(此前 todos 路由零测试覆盖),聚焦 ensureFeatureEnabled 网关:关闭时 GET/POST 均 409+reasonCode+零 todoItem 查询;开启时正常放行。 验证:server 5 个相关测试文件 286/286 通过;client typecheck/build 通过; check-ui-design-rules.mjs --strict、check-trellis-governance.mjs、 git diff --check 通过。routes/ 全量回归 872/874(2 个失败为 paymentBatches BIFF8 导出的既有环境问题,已用 git stash 验证在改动前的 HEAD 上同样失败, 与本次改动无关,不在本次范围内处理)。 剩余 669 条例外记录不变,尚未处理;B/C 两档 57 文件仍需逐模块确认迁移或 提出延期理由,本次不扩大范围。 Co-Authored-By: Claude Sonnet 5 <[email protected]>
586 行
19 KiB
JavaScript
586 行
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", () => {
|
|
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();
|
|
});
|
|
|
|
it("returns a precise reasonCode and skips business queries when portal home is disabled", async () => {
|
|
featureFlagState.portalHome = false;
|
|
const { app, prisma } = await buildApp({});
|
|
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/api/portal/home",
|
|
headers: { authorization: "Bearer test" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json()).toMatchObject({
|
|
error: {
|
|
code: "VALID_001",
|
|
details: { featureFlag: "portal_home_v1_disabled", reason: "env_hard_off", reasonCode: "portal.feature_disabled" },
|
|
},
|
|
});
|
|
expect(prisma.reimbursementForm.count).not.toHaveBeenCalled();
|
|
expect(prisma.workflowTask.count).not.toHaveBeenCalled();
|
|
|
|
await app.close();
|
|
});
|
|
});
|