feat(portal-shell): v2.0 P1-P4 token migration + unit tests + prod endpoint + e2e

P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换)
- bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground
- 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌)

P2: v2.0 新增组件单元测试补齐(5 文件 81 用例)
- permission-bitmap: 24 用例(含 GRADE_READ 重复去重)
- route-permissions: 26 用例(4 张表优先级 + AND/OR 语义)
- notify: 12 用例(sonner toast 双重性质 vi.hoisted mock)
- use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock)
- plugin-boundary: 10 用例(错误边界 + 骨架变体)

P3: 错误上报端点生产替换(后端 /api/v1/log)
- api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回)
- main.go: 注册 POST /api/v1/log 路由
- useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log)

P4: E2E 测试(3 文件 30 用例)
- streaming: 4 用例(React 19 use() + Suspense,act 包裹 render)
- error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3)
- security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围)
- vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
This commit is contained in:
SpecialX
2026-07-17 16:49:00 +08:00
parent 9cedf0c437
commit f586a0b19e
46 changed files with 2415 additions and 559 deletions

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { type ReactNode } from "react";
/**
* PluginBoundary 插件级错误边界 + 流式 Suspense 测试
*
* 覆盖:
* - 正常渲染 children
* - 子组件抛错时显示 fallback含 pluginId 和错误消息)
* - 重试按钮触发 reset
* - 5 种骨架变体渲染card/list/chart/stats/table
* - 错误自动上报 useErrorReport
* 关联portal-shell README v2.0 §5.4 三级错误处理L3 插件级)
*/
// mock useErrorReport
const reportErrorMock = vi.fn();
vi.mock("@edu/hooks", () => ({
useErrorReport: () => reportErrorMock,
}));
import {
PluginBoundary,
PluginSkeleton,
} from "@/shared/components/plugin-boundary";
// 工具:制造抛错组件
function ThrowOnRender({ message }: { message: string }): ReactNode {
throw new Error(message);
}
function GoodComponent(): ReactNode {
return <div data-testid="good"></div>;
}
describe("PluginBoundary", () => {
beforeEach(() => {
vi.clearAllMocks();
// 清除 console.error 噪音React ErrorBoundary 会打 console.error
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("正常渲染 children", () => {
render(
<PluginBoundary pluginId="test-plugin">
<GoodComponent />
</PluginBoundary>,
);
expect(screen.getByTestId("good")).toBeTruthy();
expect(screen.queryByText("插件加载失败")).toBeNull();
});
it("子组件抛错时显示 fallback", () => {
render(
<PluginBoundary pluginId="bad-plugin">
<ThrowOnRender message="渲染崩溃" />
</PluginBoundary>,
);
expect(screen.getByText("插件加载失败")).toBeTruthy();
expect(screen.getByText(/bad-plugin: 渲染崩溃/)).toBeTruthy();
});
it("错误触发 useErrorReport 上报", () => {
render(
<PluginBoundary pluginId="report-plugin">
<ThrowOnRender message="需上报的错误" />
</PluginBoundary>,
);
expect(reportErrorMock).toHaveBeenCalledTimes(1);
const [error, options] = reportErrorMock.mock.calls[0]!;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("需上报的错误");
expect(options).toEqual({
pluginId: "report-plugin",
level: "error",
});
});
it("重试按钮触发 reset 并重新渲染", () => {
let shouldThrow = true;
function FlakyComponent(): ReactNode {
if (shouldThrow) throw new Error("偶发错误");
return <div data-testid="recovered"></div>;
}
render(
<PluginBoundary pluginId="flaky-plugin">
<FlakyComponent />
</PluginBoundary>,
);
expect(screen.getByText("插件加载失败")).toBeTruthy();
// 切换为不抛错
shouldThrow = false;
fireEvent.click(screen.getByText("重试"));
expect(screen.getByTestId("recovered")).toBeTruthy();
});
});
describe("PluginSkeleton 骨架变体", () => {
it("card 变体(默认)渲染骨架", () => {
const { container } = render(<PluginSkeleton variant="card" />);
expect(container.querySelector('[role="status"]')).toBeTruthy();
expect(container.querySelector('[aria-label="加载中"]')).toBeTruthy();
});
it("list 变体渲染 4 行骨架", () => {
const { container } = render(<PluginSkeleton variant="list" />);
const skeletons = container.querySelectorAll('[class*="h-12"]');
expect(skeletons.length).toBe(4);
});
it("chart 变体渲染柱状骨架", () => {
const { container } = render(<PluginSkeleton variant="chart" />);
const bars = container.querySelectorAll('[class*="flex-1"]');
expect(bars.length).toBe(7);
});
it("stats 变体渲染 3 列统计骨架", () => {
const { container } = render(<PluginSkeleton variant="stats" />);
const cols = container.querySelectorAll(".flex-1");
expect(cols.length).toBeGreaterThanOrEqual(3);
});
it("table 变体渲染表头 + 4 行", () => {
const { container } = render(<PluginSkeleton variant="table" />);
const rows = container.querySelectorAll('[class*="h-10"]');
expect(rows.length).toBe(4);
});
it("所有变体都有 aria-live=polite 和 role=status", () => {
for (const variant of [
"card",
"list",
"chart",
"stats",
"table",
] as const) {
const { container } = render(<PluginSkeleton variant={variant} />);
const status = container.querySelector('[role="status"]');
expect(status).toBeTruthy();
expect(status?.getAttribute("aria-live")).toBe("polite");
container.remove();
}
});
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* notify 统一 Toast 封装测试
*
* 覆盖success/error/warning/info/promise/loading/message/dismiss 方法委托
* error 默认 duration=6000
* promise 透传原 Promise便于链式调用
* 关联portal-shell README v2.0 §5.4
*/
// mock sonner 模块toast 既是可调用函数又有方法success/error/etc.
// vi.hoisted 确保 mock 变量在 vi.mock 提升前初始化
const { toastMock } = vi.hoisted(() => {
const fn = vi.fn() as unknown as {
(): void;
success: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
warning: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
promise: ReturnType<typeof vi.fn>;
loading: ReturnType<typeof vi.fn>;
message: ReturnType<typeof vi.fn>;
dismiss: ReturnType<typeof vi.fn>;
};
fn.success = vi.fn();
fn.error = vi.fn();
fn.warning = vi.fn();
fn.info = vi.fn();
fn.promise = vi.fn();
fn.loading = vi.fn();
fn.message = vi.fn();
fn.dismiss = vi.fn();
return { toastMock: fn };
});
vi.mock("sonner", () => ({
toast: toastMock,
ExternalToast: {},
}));
// 导入被测模块(在 mock 之后)
import { notify } from "@/shared/lib/notify";
import { toast } from "sonner";
describe("notify", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("success", () => {
it("委托 toast.success", () => {
notify.success("保存成功");
expect(toast.success).toHaveBeenCalledWith("保存成功", undefined);
});
it("传递 options", () => {
notify.success("保存成功", { duration: 3000 });
expect(toast.success).toHaveBeenCalledWith("保存成功", {
duration: 3000,
});
});
});
describe("error", () => {
it("委托 toast.error默认 duration=6000", () => {
notify.error("网络错误");
expect(toast.error).toHaveBeenCalledWith("网络错误", {
duration: 6000,
});
});
it("options 可覆盖默认 duration", () => {
notify.error("网络错误", { duration: 2000 });
expect(toast.error).toHaveBeenCalledWith("网络错误", {
duration: 2000,
});
});
it("options 可追加 description 等字段", () => {
notify.error("网络错误", { description: "请检查网络连接" });
expect(toast.error).toHaveBeenCalledWith("网络错误", {
duration: 6000,
description: "请检查网络连接",
});
});
});
describe("warning", () => {
it("委托 toast.warning", () => {
notify.warning("警告信息");
expect(toast.warning).toHaveBeenCalledWith("警告信息", undefined);
});
});
describe("info", () => {
it("委托 toast.info", () => {
notify.info("提示信息");
expect(toast.info).toHaveBeenCalledWith("提示信息", undefined);
});
});
describe("promise", () => {
it("调用 toast.promise 并透传原 Promise", async () => {
const originalPromise = Promise.resolve("data");
const options = {
loading: "加载中",
success: "成功",
error: "失败",
};
const result = notify.promise(originalPromise, options);
expect(toast.promise).toHaveBeenCalledWith(originalPromise, options);
expect(result).toBe(originalPromise);
await expect(result).resolves.toBe("data");
});
it("支持函数式 success/error 回调", async () => {
const originalPromise = Promise.reject(new Error("fail"));
const options = {
loading: "加载中",
success: (data: unknown) => `成功: ${data}`,
error: (err: unknown) => `失败: ${(err as Error).message}`,
};
const result = notify.promise(originalPromise, options);
expect(toast.promise).toHaveBeenCalledWith(originalPromise, options);
await expect(result).rejects.toThrow("fail");
});
});
describe("loading", () => {
it("委托 toast.loading 并返回 toast id", () => {
vi.mocked(toast.loading).mockReturnValue("toast-1");
const id = notify.loading("加载中");
expect(toast.loading).toHaveBeenCalledWith("加载中", undefined);
expect(id).toBe("toast-1");
});
});
describe("message", () => {
it("委托 toast 作为函数调用", () => {
notify.message("自定义消息");
expect(toast).toHaveBeenCalledWith("自定义消息", undefined);
});
});
describe("dismiss", () => {
it("委托 toast.dismiss 关闭所有", () => {
notify.dismiss();
expect(toast.dismiss).toHaveBeenCalledWith();
});
});
});

View File

@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import {
encodePermissionsBitmap,
decodePermissionsBitmap,
hasPermissionInBitmap,
hasAnyPermissionInBitmap,
hasAllPermissionsInBitmap,
isValidPermission,
PERMISSION_BITMAP_ORDER,
} from "@edu/shared-ts/permission-bitmap";
/**
* 权限位图编解码测试
*
* 覆盖:编码/解码互逆性、空输入、未知权限、单点检查、批量检查、无效字符
* 关联portal-shell README v2.0 §3.3 三层安全边界
*/
describe("permission-bitmap", () => {
describe("encodePermissionsBitmap", () => {
it("空数组编码为 0", () => {
expect(encodePermissionsBitmap([])).toBe("0");
});
it("单个权限点编码正确", () => {
// 第一个权限点 DASHBOARD_ADMIN_READ = bit 0 → 1 → "1"
expect(encodePermissionsBitmap(["DASHBOARD_ADMIN_READ"])).toBe("1");
// 第二个权限点 DASHBOARD_TEACHER_READ = bit 1 → 2 → "2"
expect(encodePermissionsBitmap(["DASHBOARD_TEACHER_READ"])).toBe("2");
});
it("多个权限点合并编码", () => {
// bit 0 + bit 1 = 3 → "3"
const encoded = encodePermissionsBitmap([
"DASHBOARD_ADMIN_READ",
"DASHBOARD_TEACHER_READ",
]);
expect(encoded).toBe("3");
});
it("未知权限静默忽略", () => {
const valid = encodePermissionsBitmap(["DASHBOARD_ADMIN_READ"]);
const withUnknown = encodePermissionsBitmap([
"DASHBOARD_ADMIN_READ",
"UNKNOWN_PERMISSION_XYZ",
]);
expect(withUnknown).toBe(valid);
});
});
describe("decodePermissionsBitmap", () => {
it("编码解码互逆", () => {
const perms = [
"DASHBOARD_ADMIN_READ",
"USER_MANAGE",
"ROLE_READ",
"EXAM_CREATE",
];
const encoded = encodePermissionsBitmap(perms);
const decoded = decodePermissionsBitmap(encoded);
expect(decoded.sort()).toEqual([...perms].sort());
});
it("空字符串返回空数组", () => {
expect(decodePermissionsBitmap("")).toEqual([]);
});
it("无效字符返回空数组", () => {
expect(decodePermissionsBitmap("abc!def")).toEqual([]);
expect(decodePermissionsBitmap("abc def")).toEqual([]);
});
it("0 解码为空数组", () => {
expect(decodePermissionsBitmap("0")).toEqual([]);
});
});
describe("hasPermissionInBitmap", () => {
it("拥有的权限返回 true", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(hasPermissionInBitmap(encoded, "USER_MANAGE")).toBe(true);
});
it("未拥有的权限返回 false", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(hasPermissionInBitmap(encoded, "ROLE_READ")).toBe(false);
});
it("未知权限点返回 false", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(hasPermissionInBitmap(encoded, "UNKNOWN_PERM")).toBe(false);
});
it("无效位图返回 false", () => {
expect(hasPermissionInBitmap("invalid!", "USER_MANAGE")).toBe(false);
});
it("空位图返回 false", () => {
expect(hasPermissionInBitmap("", "USER_MANAGE")).toBe(false);
});
});
describe("hasAnyPermissionInBitmap", () => {
it("任一权限满足即 true", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(
hasAnyPermissionInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
).toBe(true);
});
it("全部不满足即 false", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(
hasAnyPermissionInBitmap(encoded, ["ROLE_READ", "EXAM_CREATE"]),
).toBe(false);
});
it("空数组返回 false", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(hasAnyPermissionInBitmap(encoded, [])).toBe(false);
});
});
describe("hasAllPermissionsInBitmap", () => {
it("全部权限满足即 true", () => {
const encoded = encodePermissionsBitmap([
"USER_MANAGE",
"ROLE_READ",
"EXAM_CREATE",
]);
expect(
hasAllPermissionsInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
).toBe(true);
});
it("部分不满足即 false", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(
hasAllPermissionsInBitmap(encoded, ["USER_MANAGE", "ROLE_READ"]),
).toBe(false);
});
it("空数组返回 truevacuous truth", () => {
const encoded = encodePermissionsBitmap(["USER_MANAGE"]);
expect(hasAllPermissionsInBitmap(encoded, [])).toBe(true);
});
});
describe("isValidPermission", () => {
it("已知权限点返回 true", () => {
expect(isValidPermission("DASHBOARD_ADMIN_READ")).toBe(true);
expect(isValidPermission("USER_MANAGE")).toBe(true);
});
it("未知权限点返回 false", () => {
expect(isValidPermission("UNKNOWN_PERM")).toBe(false);
expect(isValidPermission("")).toBe(false);
});
it("PERMISSION_BITMAP_ORDER 全部合法", () => {
for (const perm of PERMISSION_BITMAP_ORDER) {
expect(isValidPermission(perm)).toBe(true);
}
});
});
describe("全量编解码压力测试", () => {
it("全部权限点编码后解码应还原(去重比较,已知 GRADE_READ 在 ORDER 中重复)", () => {
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
const encoded = encodePermissionsBitmap(allPerms);
const decoded = decodePermissionsBitmap(encoded);
expect(decoded.sort()).toEqual([...allPerms].sort());
});
it("全部权限点位图长度合理base36 < 20 字符)", () => {
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
const encoded = encodePermissionsBitmap(allPerms);
// 67 bit → base36 约 14 字符
expect(encoded.length).toBeLessThan(20);
});
});
});

View File

@@ -0,0 +1,300 @@
import { describe, it, expect } from "vitest";
import {
checkRoutePermission,
batchCheckRoutePermission,
validateRoutePermissionConfigs,
EXACT_ROUTE_PERMISSIONS,
PREFIX_ROUTE_PERMISSIONS,
DASHBOARD_ROUTE_PERMISSIONS,
} from "@/shared/lib/route-permissions";
import { encodePermissionsBitmap } from "@edu/shared-ts/permission-bitmap";
/**
* 路由权限配置测试
*
* 覆盖4 张表优先级匹配、L1 角色门禁、L2 权限点门禁AND/OR、公共路由放行、批量检查、配置合法性
* 关联portal-shell README v2.0 §3.3 三层安全边界
*/
// 测试用 bitmap
const ADMIN_BITMAP = encodePermissionsBitmap([
"DASHBOARD_ADMIN_READ",
"USER_MANAGE",
"ROLE_MANAGE",
"PERMISSION_MANAGE",
"AUDIT_LOG_READ",
"SCHOOL_MANAGE",
"PLUGIN_REGISTRY_MANAGE",
"DASHBOARD_READ",
]);
const TEACHER_BITMAP = encodePermissionsBitmap([
"DASHBOARD_TEACHER_READ",
"DASHBOARD_READ",
"LESSON_PLAN_READ",
"QUESTION_READ",
"TEXTBOOK_READ",
]);
const STUDENT_BITMAP = encodePermissionsBitmap([
"DASHBOARD_STUDENT_READ",
"DASHBOARD_READ",
"ERROR_BOOK_READ",
"LEARNING_PATH_READ",
"AI_TUTOR_USE",
]);
const PARENT_BITMAP = encodePermissionsBitmap([
"DASHBOARD_PARENT_READ",
"DASHBOARD_READ",
"GRADE_READ_CHILD",
"LEAVE_APPROVAL_MANAGE",
]);
describe("route-permissions", () => {
describe("EXACT 路由匹配(最高优先级)", () => {
it("admin 用户访问 /shell/admin/users 且有 USER_MANAGE 权限 → 放行", () => {
const result = checkRoutePermission(
"/shell/admin/users",
ADMIN_BITMAP,
"admin",
);
expect(result.allowed).toBe(true);
expect(result.matchedPath).toBe("/shell/admin/users");
});
it("teacher 角色访问 admin 路由 → 拒绝missing_role", () => {
const result = checkRoutePermission(
"/shell/admin/users",
ADMIN_BITMAP,
"teacher",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_role");
});
it("admin 角色但缺少 USER_MANAGE 权限 → 拒绝missing_permission", () => {
const noUserManage = encodePermissionsBitmap([
"DASHBOARD_ADMIN_READ",
"DASHBOARD_READ",
]);
const result = checkRoutePermission(
"/shell/admin/users",
noUserManage,
"admin",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_permission");
expect(result.missingPermissions).toEqual(["USER_MANAGE"]);
});
it("anyOfPermissions OR 语义:满足任一即放行", () => {
const result = checkRoutePermission(
"/shell/admin/invitation-codes",
encodePermissionsBitmap(["INVITATION_CODE_CREATE"]),
"admin",
);
expect(result.allowed).toBe(true);
});
it("anyOfPermissions OR 语义:全不满足即拒绝", () => {
const result = checkRoutePermission(
"/shell/admin/invitation-codes",
encodePermissionsBitmap([]),
"admin",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_permission");
});
});
describe("PREFIX 路由匹配(中优先级)", () => {
it("/shell/admin/ 子路由要求 admin 角色", () => {
const result = checkRoutePermission(
"/shell/admin/unknown-page",
ADMIN_BITMAP,
"admin",
);
expect(result.allowed).toBe(true);
expect(result.matchedPath).toBe("/shell/admin/");
});
it("/shell/admin/ 子路由拒绝非 admin 角色", () => {
const result = checkRoutePermission(
"/shell/admin/unknown-page",
TEACHER_BITMAP,
"teacher",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_role");
});
it("/shell/teacher/exams/ 前缀匹配", () => {
const result = checkRoutePermission(
"/shell/teacher/exams/123",
TEACHER_BITMAP,
"teacher",
);
// TEACHER_BITMAP 没有 EXAM_READ需要检查 anyOf
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_permission");
});
it("前缀匹配不误匹配(/shell/admin 不应匹配 /shell/admin-users", () => {
// /shell/admin-users 不匹配 /shell/admin/ 前缀(因为前缀以 / 结尾)
const result = checkRoutePermission(
"/shell/admin-users",
ADMIN_BITMAP,
"admin",
);
// 不匹配任何前缀,应走到仪表盘或公共路由
expect(result.matchedPath).not.toBe("/shell/admin/");
});
});
describe("DASHBOARD 路由匹配(低优先级)", () => {
it("admin 仪表盘根路径", () => {
const result = checkRoutePermission(
"/shell/admin",
ADMIN_BITMAP,
"admin",
);
expect(result.allowed).toBe(true);
expect(result.matchedPath).toBe("/shell/admin");
});
it("teacher 仪表盘根路径", () => {
const result = checkRoutePermission(
"/shell/teacher",
TEACHER_BITMAP,
"teacher",
);
expect(result.allowed).toBe(true);
});
it("student 仪表盘根路径", () => {
const result = checkRoutePermission(
"/shell/student",
STUDENT_BITMAP,
"student",
);
expect(result.allowed).toBe(true);
});
it("parent 仪表盘根路径", () => {
const result = checkRoutePermission(
"/shell/parent",
PARENT_BITMAP,
"parent",
);
expect(result.allowed).toBe(true);
});
it("角色不匹配 → 拒绝", () => {
const result = checkRoutePermission(
"/shell/admin",
TEACHER_BITMAP,
"teacher",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("missing_role");
});
it("通用仪表盘 /shell 只需 DASHBOARD_READ", () => {
const result = checkRoutePermission(
"/shell",
encodePermissionsBitmap(["DASHBOARD_READ"]),
"teacher",
);
expect(result.allowed).toBe(true);
});
});
describe("API 路由匹配", () => {
it("/api/log 所有登录用户可访问", () => {
const result = checkRoutePermission(
"/api/log",
encodePermissionsBitmap([]),
"student",
);
expect(result.allowed).toBe(true);
});
it("/api/healthz 公开访问", () => {
const result = checkRoutePermission(
"/api/healthz",
encodePermissionsBitmap([]),
"student",
);
expect(result.allowed).toBe(true);
});
it("未配置的 API 路由默认拒绝", () => {
const result = checkRoutePermission(
"/api/unknown",
ADMIN_BITMAP,
"admin",
);
expect(result.allowed).toBe(false);
expect(result.reason).toBe("no_config");
});
});
describe("公共路由默认放行", () => {
it("/ 根路径放行", () => {
const result = checkRoutePermission("/", "", "student");
expect(result.allowed).toBe(true);
});
it("/login 放行", () => {
const result = checkRoutePermission("/login", "", "student");
expect(result.allowed).toBe(true);
});
it("/shell/forbidden 放行", () => {
const result = checkRoutePermission("/shell/forbidden", "", "student");
expect(result.allowed).toBe(true);
});
});
describe("batchCheckRoutePermission", () => {
it("批量检查多个路径", () => {
const paths = [
"/shell/admin/users",
"/shell/teacher",
"/shell/student",
"/api/log",
];
const result = batchCheckRoutePermission(paths, ADMIN_BITMAP, "admin");
expect(result["/shell/admin/users"]).toBe(true);
expect(result["/shell/teacher"]).toBe(false); // admin 没有 DASHBOARD_TEACHER_READ
expect(result["/shell/student"]).toBe(false);
expect(result["/api/log"]).toBe(true);
});
});
describe("validateRoutePermissionConfigs", () => {
it("所有配置的权限点应合法", () => {
const invalid = validateRoutePermissionConfigs();
expect(invalid).toEqual([]);
});
});
describe("配置表非空校验", () => {
it("EXACT_ROUTE_PERMISSIONS 非空", () => {
expect(Object.keys(EXACT_ROUTE_PERMISSIONS).length).toBeGreaterThan(0);
});
it("PREFIX_ROUTE_PERMISSIONS 非空", () => {
expect(PREFIX_ROUTE_PERMISSIONS.length).toBeGreaterThan(0);
});
it("DASHBOARD_ROUTE_PERMISSIONS 包含 4 个角色 + 通用", () => {
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/admin"]).toBeDefined();
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/teacher"]).toBeDefined();
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/student"]).toBeDefined();
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell/parent"]).toBeDefined();
expect(DASHBOARD_ROUTE_PERMISSIONS["/shell"]).toBeDefined();
});
});
});

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useErrorReport } from "@edu/hooks";
/**
* useErrorReport 错误上报 Hook 测试
*
* 覆盖:
* - sendBeacon 上报路径和 payload 结构
* - fetch keepalive 降级
* - sessionStorage 节流(同 digest 1 分钟内只上报一次)
* - userId 从 localStorage 读取
* - 上报失败静默降级
* 关联portal-shell README v2.0 §5.4 三级错误处理
*/
// mock navigator.sendBeacon
const sendBeaconMock = vi.fn().mockReturnValue(true);
// mock fetch
const fetchMock = vi.fn().mockResolvedValue(new Response());
// 存储最近一次 sendBeacon 的 body 字符串(绕过 jsdom Blob 读取限制)
let lastBeaconBody: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
lastBeaconBody = undefined;
sessionStorage.clear();
localStorage.clear();
// mock Blob 构造函数,捕获 body 字符串
vi.stubGlobal(
"Blob",
vi.fn((parts: BlobPart[], options?: { type?: string }) => {
lastBeaconBody = String(parts[0]);
return { type: options?.type ?? "", size: lastBeaconBody.length };
}),
);
Object.defineProperty(window, "navigator", {
value: {
sendBeacon: sendBeaconMock,
userAgent: "test-agent",
},
writable: true,
});
vi.stubGlobal("fetch", fetchMock);
});
/** 从最近一次 sendBeacon 调用中提取 payload */
function getLastPayload(): Record<string, unknown> {
if (!lastBeaconBody) throw new Error("No sendBeacon body captured");
return JSON.parse(lastBeaconBody);
}
afterEach(() => {
vi.restoreAllMocks();
sessionStorage.clear();
localStorage.clear();
});
describe("useErrorReport", () => {
it("通过 sendBeacon 上报到 /api/log", () => {
const { result } = renderHook(() => useErrorReport());
const error = new Error("测试错误");
act(() => {
result.current(error);
});
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
const [endpoint, blob] = sendBeaconMock.mock.calls[0]!;
expect(endpoint).toBe("/api/log");
expect(blob).toBeTruthy();
expect(blob.type).toBe("application/json");
});
it("payload 包含必需字段", () => {
const { result } = renderHook(() => useErrorReport());
const error = new Error("测试错误");
error.stack = "Error: 测试错误\n at test";
act(() => {
result.current(error);
});
const payload = getLastPayload();
expect(payload.level).toBe("error");
expect(payload.message).toBe("测试错误");
expect(payload.stack).toBe("Error: 测试错误\n at test");
expect(payload.path).toBe("/");
expect(payload.userAgent).toBe("test-agent");
expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
expect(payload.digest).toBeTruthy();
});
it("支持 pluginId 和 level 选项", () => {
const { result } = renderHook(() => useErrorReport());
const error = new Error("插件错误");
act(() => {
result.current(error, {
pluginId: "grades-widget",
level: "warning",
context: { widgetId: "g1" },
});
});
const payload = getLastPayload();
expect(payload.pluginId).toBe("grades-widget");
expect(payload.level).toBe("warning");
expect(payload.context).toEqual({ widgetId: "g1" });
});
it("从 localStorage 读取 userId", () => {
localStorage.setItem("edu_user_id", "user-123");
const { result } = renderHook(() => useErrorReport());
act(() => {
result.current(new Error("测试"));
});
const payload = getLastPayload();
expect(payload.userId).toBe("user-123");
});
it("节流:同 digest 1 分钟内只上报一次", () => {
const { result } = renderHook(() => useErrorReport());
const error = new Error("重复错误");
act(() => {
result.current(error);
});
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
// 第二次相同错误应被节流
act(() => {
result.current(error);
});
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
});
it("不同 digest 的错误不互相影响", () => {
const { result } = renderHook(() => useErrorReport());
act(() => {
result.current(new Error("错误A"));
});
act(() => {
result.current(new Error("错误B"));
});
expect(sendBeaconMock).toHaveBeenCalledTimes(2);
});
it("sendBeacon 返回 false 时降级到 fetch keepalive", () => {
sendBeaconMock.mockReturnValue(false);
const { result } = renderHook(() => useErrorReport());
act(() => {
result.current(new Error("降级测试"));
});
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [endpoint, init] = fetchMock.mock.calls[0]!;
expect(endpoint).toBe("/api/log");
expect(init.method).toBe("POST");
expect(init.keepalive).toBe(true);
expect(init.credentials).toBe("include");
});
it("sendBeacon 抛异常时降级到 fetch", () => {
sendBeaconMock.mockImplementation(() => {
throw new Error("sendBeacon 不可用");
});
const { result } = renderHook(() => useErrorReport());
act(() => {
result.current(new Error("降级测试"));
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("reportError 是稳定的 useCallback空依赖", () => {
const { result, rerender } = renderHook(() => useErrorReport());
const first = result.current;
rerender();
expect(result.current).toBe(first);
});
});