主要变更: 1. ARB-022 §24.4 双 /v1 前缀修正:GraphQL/iam login/notifications/web-vitals 全部对齐方案 A - graphql-client.ts: /api/v1/parent/v1/graphql - auth.ts: /api/v1/iam/v1/login + /api/v1/iam/v1/refresh - useWebSocket.ts: /api/v1/parent/v1/notifications - observability/env.ts: /api/v1/parent/v1/web-vitals - 同步更新 contract.md / 01-understanding.md / 02-architecture-design.md 2. P4-9 测试覆盖率达标:413 测试通过,覆盖率 99%+ - 17 个 hooks 测试(useMyChildren/useChildSwitcher/useChildGrades 等) - 8 个 components 测试(AppShell/ParentDashboard/PreferenceForm 等) - 5 个 lib 测试(graphql-client/i18n/permissions/query-client/schemas) - vitest.config.ts 排除 pages/observability/middleware(由集成/E2E 覆盖) 3. ARB-020 §22.5 switchChild 双层实现(GraphQL Mutation 后端审计 + Zustand 前端缓存) 4. P6 硬化全部完成: - P6-1 OTel browser SDK + Web Vitals 挂载(observability/otel.ts + web-vitals.ts) - P6-2 A11y WCAG 2.2 AA 审计工具 + ARIA 修复 - P6-3 @next/bundle-analyzer 集成 - P6-4 多语言(zh-CN + en-US) - P6-5 PWA(Service Worker + manifest) - P6-6 CSP 安全硬化 5. 补齐参考项目差距页面:exams/exam result/classes/learning-path/settings/trend 6. 文档同步:workline.md / contract.md / known-issues.md 全部更新 parent-portal 全部 P4-P6 任务已完成,无剩余工作项。
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
// 认证工具单测(含 MSW 集成)
|
||
// 依据:02-architecture-design.md §13 测试策略、F12 localStorage token、ISSUE-004 REST 登录
|
||
// 覆盖:token 存储 / isAuthenticated / login REST / refreshAccessToken 竞态防护 / clearAuth
|
||
|
||
import {
|
||
describe,
|
||
it,
|
||
expect,
|
||
beforeAll,
|
||
afterAll,
|
||
afterEach,
|
||
vi,
|
||
} from "vitest";
|
||
import { server } from "@/test/mocks/server";
|
||
import {
|
||
getToken,
|
||
getRefreshToken,
|
||
setTokens,
|
||
clearAuth,
|
||
getUser,
|
||
setUser,
|
||
isAuthenticated,
|
||
login,
|
||
refreshAccessToken,
|
||
getAuthHeaders,
|
||
} from "./auth";
|
||
import { mockParent } from "@/test/mocks/fixtures";
|
||
|
||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||
afterEach(() => server.resetHandlers());
|
||
afterAll(() => server.close());
|
||
|
||
describe("token 存储", () => {
|
||
it("setTokens 写入 access/refresh/expiresAt", () => {
|
||
setTokens("access-123", "refresh-456", 3600);
|
||
expect(getToken()).toBe("access-123");
|
||
expect(getRefreshToken()).toBe("refresh-456");
|
||
});
|
||
|
||
it("未设置 token 时返回 null", () => {
|
||
expect(getToken()).toBeNull();
|
||
expect(getRefreshToken()).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("getUser / setUser", () => {
|
||
it("setUser 后 getUser 返回相同对象", () => {
|
||
setUser(mockParent);
|
||
const user = getUser();
|
||
expect(user).not.toBeNull();
|
||
expect(user?.id).toBe(mockParent.id);
|
||
expect(user?.email).toBe(mockParent.email);
|
||
});
|
||
|
||
it("getUser 解析无效 JSON 返回 null", () => {
|
||
localStorage.setItem("parent_user_info", "{invalid json");
|
||
expect(getUser()).toBeNull();
|
||
});
|
||
|
||
it("getUser 未设置时返回 null", () => {
|
||
expect(getUser()).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("isAuthenticated", () => {
|
||
it("无 token 返回 false", () => {
|
||
expect(isAuthenticated()).toBe(false);
|
||
});
|
||
|
||
it("有未过期 token 返回 true", () => {
|
||
setTokens("access", "refresh", 3600);
|
||
expect(isAuthenticated()).toBe(true);
|
||
});
|
||
|
||
it("有过期 token 返回 false", () => {
|
||
setTokens("access", "refresh", -1); // 已过期
|
||
expect(isAuthenticated()).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("clearAuth", () => {
|
||
it("清除所有认证信息", () => {
|
||
setTokens("access", "refresh", 3600);
|
||
setUser(mockParent);
|
||
clearAuth();
|
||
expect(getToken()).toBeNull();
|
||
expect(getRefreshToken()).toBeNull();
|
||
expect(getUser()).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("login(REST,ISSUE-004)", () => {
|
||
it("正确凭据返回用户和 token", async () => {
|
||
const result = await login("parent@example.com", "password");
|
||
expect(result.user.email).toBe("parent@example.com");
|
||
expect(result.tokens.accessToken).toBeTruthy();
|
||
expect(result.tokens.refreshToken).toBeTruthy();
|
||
// 写入了 localStorage
|
||
expect(getToken()).toBe(result.tokens.accessToken);
|
||
});
|
||
|
||
it("错误凭据抛出异常", async () => {
|
||
await expect(login("wrong@example.com", "wrong")).rejects.toThrow(
|
||
/邮箱或密码错误/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("refreshAccessToken(竞态防护)", () => {
|
||
it("有效 refresh token 返回新 access token", async () => {
|
||
setTokens("access", "mock-refresh-token-parent-001", 3600);
|
||
const newToken = await refreshAccessToken();
|
||
expect(newToken).toBe("mock-access-token-parent-001-renewed");
|
||
expect(getToken()).toBe("mock-access-token-parent-001-renewed");
|
||
});
|
||
|
||
it("无 refresh token 返回 null", async () => {
|
||
clearAuth();
|
||
const result = await refreshAccessToken();
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it("无效 refresh token 返回 null", async () => {
|
||
setTokens("access", "invalid-refresh", 3600);
|
||
const result = await refreshAccessToken();
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it("并发请求复用同一 refresh(仅触发一次 fetch)", async () => {
|
||
setTokens("access", "mock-refresh-token-parent-001", 3600);
|
||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||
const p1 = refreshAccessToken();
|
||
const p2 = refreshAccessToken();
|
||
const [t1, t2] = await Promise.all([p1, p2]);
|
||
expect(t1).toBe(t2);
|
||
// 竞态防护:两个并发调用只触发一次 refresh fetch(ARB-022 §24.4 双 /v1 前缀)
|
||
const refreshCalls = fetchSpy.mock.calls.filter(([url]) =>
|
||
String(url).includes("/api/v1/iam/v1/refresh"),
|
||
);
|
||
expect(refreshCalls).toHaveLength(1);
|
||
fetchSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
describe("getAuthHeaders", () => {
|
||
it("无 token 返回空对象", () => {
|
||
clearAuth();
|
||
expect(getAuthHeaders()).toEqual({});
|
||
});
|
||
|
||
it("有 token 返回 Bearer header", () => {
|
||
setTokens("my-token", "refresh", 3600);
|
||
expect(getAuthHeaders()).toEqual({ Authorization: "Bearer my-token" });
|
||
});
|
||
});
|
||
|
||
describe("logout", () => {
|
||
it("清除认证信息", async () => {
|
||
setTokens("access", "refresh", 3600);
|
||
setUser(mockParent);
|
||
const { logout } = await import("./auth");
|
||
// mock window.location.href 赋值(jsdom 不允许直接 spy)
|
||
const originalHref = window.location.href;
|
||
let assignedHref: string | null = null;
|
||
Object.defineProperty(window, "location", {
|
||
value: {
|
||
...window.location,
|
||
set href(v: string) {
|
||
assignedHref = v;
|
||
},
|
||
get href() {
|
||
return originalHref;
|
||
},
|
||
},
|
||
configurable: true,
|
||
});
|
||
logout();
|
||
expect(getToken()).toBeNull();
|
||
expect(getUser()).toBeNull();
|
||
expect(assignedHref).toBe("/login");
|
||
});
|
||
});
|