docs(admin-portal): 新增 nextstep-v2.md 记录下游核查结果

v1 声称完成的下游工作经核查实际未完成:
- api-gateway: /api/admin/graphql 路由未注册,go vet 编译失败
- teacher-bff: resolver 已完成但 schema 未同步(命名空间 vs 扁平)
- iam: proto 缺 BatchGetUsers rpc 声明

v2 记录详细核查证据和修复要求
This commit is contained in:
SpecialX
2026-07-14 08:26:27 +08:00
parent 99580fa13a
commit 0b42302a64
42 changed files with 4804 additions and 637 deletions

View File

@@ -0,0 +1,225 @@
/**
* useCrossTabSync Hook 单元测试
*
* 测试点:
* - BroadcastChannel 触发 message 事件时回调被调用token-refresh → setToken
* - 卸载时关闭 channelclose 被调用)
* - API 不可用时的兜底typeof BroadcastChannel === 'undefined'
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import type { CrossTabMessage } from "../use-cross-tab-sync";
// mock @/lib/auth 的 setTokenhook 内部依赖)
vi.mock("@/lib/auth", () => ({
setToken: vi.fn(),
getToken: vi.fn(() => null),
clearToken: vi.fn(),
getUser: vi.fn(() => null),
setUser: vi.fn(),
isAuthenticated: vi.fn(() => false),
login: vi.fn(),
logout: vi.fn(),
}));
import { setToken } from "@/lib/auth";
// ============ 可控 BroadcastChannel mock ============
class TestableBroadcastChannel {
name: string;
onmessage: ((event: MessageEvent) => void) | null = null;
listeners: Map<string, Set<(event: MessageEvent) => void>> = new Map();
closeCount = 0;
static instances: TestableBroadcastChannel[] = [];
static lastInstance: TestableBroadcastChannel | null = null;
constructor(name: string) {
this.name = name;
TestableBroadcastChannel.instances.push(this);
TestableBroadcastChannel.lastInstance = this;
}
addEventListener(
type: string,
listener: (event: MessageEvent) => void,
): void {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
this.listeners.get(type)!.add(listener);
}
removeEventListener(
type: string,
listener: (event: MessageEvent) => void,
): void {
this.listeners.get(type)?.delete(listener);
}
postMessage(): void {
// 本测试中不模拟跨 Tab 传递
}
close(): void {
this.closeCount++;
}
/** 测试辅助:触发 message 事件给注册的监听器 */
dispatchMessage(data: CrossTabMessage): void {
const event = new MessageEvent("message", { data });
this.listeners.get("message")?.forEach((fn) => fn(event));
if (this.onmessage) {
this.onmessage(event);
}
}
static reset(): void {
TestableBroadcastChannel.instances = [];
TestableBroadcastChannel.lastInstance = null;
}
}
describe("useCrossTabSync", () => {
let originalBroadcastChannel: typeof BroadcastChannel | undefined;
beforeEach(() => {
vi.clearAllMocks();
TestableBroadcastChannel.reset();
// 保存原始 BroadcastChannel
originalBroadcastChannel = globalThis.BroadcastChannel;
// 挂载可控 mock测试中需要 as 断言挂载全局 mock
globalThis.BroadcastChannel =
TestableBroadcastChannel as unknown as typeof BroadcastChannel;
});
afterEach(() => {
// 恢复原始 BroadcastChannel
if (originalBroadcastChannel) {
globalThis.BroadcastChannel = originalBroadcastChannel;
} else {
// 原始环境无 BroadcastChanneljsdom恢复为 setup 中的 mock
delete (globalThis as Record<string, unknown>).BroadcastChannel;
}
vi.restoreAllMocks();
});
it("收到 token-refresh 事件时调用 setToken", async () => {
const { useCrossTabSync } = await import("../use-cross-tab-sync");
renderHook(() => useCrossTabSync());
const channel = TestableBroadcastChannel.lastInstance;
expect(channel).not.toBeNull();
expect(channel!.name).toBe("edu-session");
const token = "new-token-abc123";
channel!.dispatchMessage({
type: "token-refresh",
payload: { token },
timestamp: Date.now(),
});
expect(setToken).toHaveBeenCalledWith(token);
});
it("收到 logout 事件时跳转登录页", async () => {
const { useCrossTabSync } = await import("../use-cross-tab-sync");
renderHook(() => useCrossTabSync());
const channel = TestableBroadcastChannel.lastInstance;
expect(channel).not.toBeNull();
// mock window.location.href setter
const hrefSetter = vi.fn();
Object.defineProperty(window, "location", {
writable: true,
configurable: true,
value: {
...window.location,
set href(val: string) {
hrefSetter(val);
},
get href() {
return "";
},
reload: vi.fn(),
},
});
channel!.dispatchMessage({
type: "logout",
timestamp: Date.now(),
});
expect(hrefSetter).toHaveBeenCalledWith("/login");
});
it("收到 role-change 事件时刷新页面", async () => {
const { useCrossTabSync } = await import("../use-cross-tab-sync");
const reloadSpy = vi.fn();
Object.defineProperty(window, "location", {
writable: true,
configurable: true,
value: {
...window.location,
href: "",
reload: reloadSpy,
},
});
renderHook(() => useCrossTabSync());
const channel = TestableBroadcastChannel.lastInstance;
expect(channel).not.toBeNull();
channel!.dispatchMessage({
type: "role-change",
timestamp: Date.now(),
});
expect(reloadSpy).toHaveBeenCalled();
});
it("卸载时关闭 channel", async () => {
const { useCrossTabSync } = await import("../use-cross-tab-sync");
const { unmount } = renderHook(() => useCrossTabSync());
const channel = TestableBroadcastChannel.lastInstance;
expect(channel).not.toBeNull();
expect(channel!.closeCount).toBe(0);
unmount();
expect(channel!.closeCount).toBe(1);
});
it("BroadcastChannel 不可用时不崩溃", async () => {
// 删除 BroadcastChannel 模拟 API 不可用环境
delete (globalThis as Record<string, unknown>).BroadcastChannel;
const { useCrossTabSync } = await import("../use-cross-tab-sync");
// 不应抛出异常
const { unmount } = renderHook(() => useCrossTabSync());
unmount();
// 无 channel 创建setToken 未被调用
expect(setToken).not.toHaveBeenCalled();
});
it("忽略无效消息体", async () => {
const { useCrossTabSync } = await import("../use-cross-tab-sync");
renderHook(() => useCrossTabSync());
const channel = TestableBroadcastChannel.lastInstance;
expect(channel).not.toBeNull();
// 发送 null 消息(不应触发任何副作用)
const event = new MessageEvent("message", { data: null });
channel!.listeners.get("message")?.forEach((fn) => fn(event));
expect(setToken).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,227 @@
/**
* useNotificationsWebSocket Hook 单元测试
*
* 测试点:
* - 连接成功后 onMessage 回调(通知添加到列表)
* - 指数退避重连1s/2s/4s/8s/16smax 30s达到上限后停止
* - 手动 reconnect 调用(关闭旧连接 + 重新开始)
* - unmount 时清理 setInterval + WebSocket
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useNotificationsWebSocket } from "../use-notifications-websocket";
// ============ 可控 WebSocket mock ============
class MockWebSocket {
url: string;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
readyState = 0;
closed = false;
static instances: MockWebSocket[] = [];
static closeCount = 0;
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
close(): void {
this.closed = true;
MockWebSocket.closeCount++;
}
send(): void {
// no-op
}
// ---- 测试辅助方法 ----
triggerOpen(): void {
this.readyState = 1;
this.onopen?.(new Event("open"));
}
triggerMessage(data: string): void {
this.onmessage?.(new MessageEvent("message", { data }));
}
triggerError(): void {
this.onerror?.(new Event("error"));
}
triggerClose(): void {
this.readyState = 3;
// 测试中需要 as 断言jsdom 的 Event 不完全匹配 CloseEvent 类型,
// 但 hook 的 onclose 不使用 event 参数,类型不匹配无副作用
this.onclose?.(new Event("close") as unknown as CloseEvent);
}
static reset(): void {
MockWebSocket.instances = [];
MockWebSocket.closeCount = 0;
}
}
describe("useNotificationsWebSocket", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubEnv("NEXT_PUBLIC_API_MOCKING", "disabled");
MockWebSocket.reset();
vi.stubGlobal("WebSocket", MockWebSocket);
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe("连接成功", () => {
it("onopen 后状态为 connectedonmessage 回调添加通知", () => {
const { result } = renderHook(() => useNotificationsWebSocket());
// 初始状态connectinguseEffect 已执行 connect
expect(result.current.connectionState).toBe("connecting");
expect(MockWebSocket.instances).toHaveLength(1);
const ws = MockWebSocket.instances[0]!;
// 触发 onopen连接成功
act(() => ws.triggerOpen());
expect(result.current.connectionState).toBe("connected");
// 触发 onmessage收到通知
const notification = {
id: "test-ntf-001",
type: "HOMEWORK_SUBMITTED",
title: "测试通知",
message: "测试消息内容",
read: false,
createdAt: new Date().toISOString(),
};
act(() => {
ws.triggerMessage(
JSON.stringify({ type: "notification", payload: notification }),
);
});
expect(result.current.notifications).toHaveLength(1);
expect(result.current.notifications[0]!.id).toBe("test-ntf-001");
});
it("重复 ID 的通知不重复添加", () => {
const { result } = renderHook(() => useNotificationsWebSocket());
const ws = MockWebSocket.instances[0]!;
const notification = {
id: "dup-001",
type: "EXAM_GRADED",
title: "重复测试",
message: "重复消息",
read: false,
createdAt: new Date().toISOString(),
};
act(() => {
ws.triggerMessage(JSON.stringify({ payload: notification }));
});
expect(result.current.notifications).toHaveLength(1);
// 再次发送相同 ID去重
act(() => {
ws.triggerMessage(JSON.stringify({ payload: notification }));
});
expect(result.current.notifications).toHaveLength(1);
});
});
describe("指数退避重连", () => {
it("连接失败后按 1s/2s/4s/8s/16s 间隔重连,达到上限后停止", () => {
const { result } = renderHook(() => useNotificationsWebSocket());
// 初始连接1 个 WebSocket 实例
expect(MockWebSocket.instances).toHaveLength(1);
// MAX_RETRIES=5, MAX_DELAY_MS=30000
// 退避序列retry0=1s, retry1=2s, retry2=4s, retry3=8s, retry4=16s均 < 30s cap
const delays = [1000, 2000, 4000, 8000, 16000];
for (let i = 0; i < delays.length; i++) {
const delay = delays[i]!;
// 触发连接失败
act(() => MockWebSocket.instances[i]!.triggerClose());
expect(result.current.connectionState).toBe("disconnected");
// 间隔前 1ms尚未重连
act(() => vi.advanceTimersByTime(delay - 1));
expect(MockWebSocket.instances).toHaveLength(i + 1);
// 到达间隔:重连,新实例创建
act(() => vi.advanceTimersByTime(1));
expect(MockWebSocket.instances).toHaveLength(i + 2);
expect(result.current.connectionState).toBe("connecting");
}
// 5 次重试后retryRef=5 >= MAX_RETRIES不再重连
act(() => MockWebSocket.instances[5]!.triggerClose());
// 推进 60s远超 max 30s cap无新实例
act(() => vi.advanceTimersByTime(60000));
expect(MockWebSocket.instances).toHaveLength(6);
});
});
describe("手动 reconnect", () => {
it("调用 reconnect 关闭旧连接并重新开始", () => {
const { result } = renderHook(() => useNotificationsWebSocket());
expect(MockWebSocket.instances).toHaveLength(1);
const oldWs = MockWebSocket.instances[0]!;
// 调用 reconnect
act(() => result.current.reconnect());
// 旧 WebSocket 已关闭
expect(oldWs.closed).toBe(true);
// 新连接已建立
expect(MockWebSocket.instances).toHaveLength(2);
expect(result.current.connectionState).toBe("connecting");
});
});
describe("卸载清理", () => {
it("unmount 时关闭 WebSocket 连接", () => {
const { unmount } = renderHook(() => useNotificationsWebSocket());
const ws = MockWebSocket.instances[0]!;
expect(ws.closed).toBe(false);
unmount();
expect(ws.closed).toBe(true);
});
it("unmount 后不再触发重连定时器", () => {
const { unmount } = renderHook(() => useNotificationsWebSocket());
// 触发连接失败,调度重连定时器
act(() => MockWebSocket.instances[0]!.triggerClose());
unmount();
const countBefore = MockWebSocket.instances.length;
// 推进 60sdisposed=true 阻止重连,无新实例
act(() => vi.advanceTimersByTime(60000));
expect(MockWebSocket.instances.length).toBe(countBefore);
});
});
});