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:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* useCrossTabSync Hook 单元测试
|
||||
*
|
||||
* 测试点:
|
||||
* - BroadcastChannel 触发 message 事件时回调被调用(token-refresh → setToken)
|
||||
* - 卸载时关闭 channel(close 被调用)
|
||||
* - 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 的 setToken(hook 内部依赖)
|
||||
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 {
|
||||
// 原始环境无 BroadcastChannel(jsdom),恢复为 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* useNotificationsWebSocket Hook 单元测试
|
||||
*
|
||||
* 测试点:
|
||||
* - 连接成功后 onMessage 回调(通知添加到列表)
|
||||
* - 指数退避重连(1s/2s/4s/8s/16s,max 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 后状态为 connected,onmessage 回调添加通知", () => {
|
||||
const { result } = renderHook(() => useNotificationsWebSocket());
|
||||
|
||||
// 初始状态:connecting(useEffect 已执行 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;
|
||||
|
||||
// 推进 60s:disposed=true 阻止重连,无新实例
|
||||
act(() => vi.advanceTimersByTime(60000));
|
||||
expect(MockWebSocket.instances.length).toBe(countBefore);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user