Files
Edu/apps/parent-portal/src/lib/auth.test.ts
SpecialX 7c8e0f5dea feat(parent-portal): 完成 P4-P6 全部任务 + ARB-022 §24.4 双 /v1 修正 + 413 测试通过
主要变更:
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 任务已完成,无剩余工作项。
2026-07-13 13:10:07 +08:00

183 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 认证工具单测(含 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("loginRESTISSUE-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 fetchARB-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");
});
});