Files
Edu/apps/parent-portal/src/hooks/useNotificationPreferences.test.tsx
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

349 lines
10 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.
// useNotificationPreferences Hook 单测
// 依据02-architecture-design.md §14 通知偏好、ISSUE-033 P4 localStorage 降级
// 覆盖localStorage 优先加载 / GraphQL 默认值降级 / updatePreference 写入 + mutation / 错误态
import {
describe,
it,
expect,
beforeAll,
afterAll,
afterEach,
beforeEach,
vi,
} from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import {
Provider as UrqlProvider,
Client,
cacheExchange,
fetchExchange,
} from "urql";
import { graphql, HttpResponse } from "msw";
import type { ReactNode } from "react";
import { server } from "@/test/mocks/server";
import { useNotificationPreferences } from "./useNotificationPreferences";
import type { NotificationPreferences } from "@/types";
const PREFS_KEY = "parent_notification_preferences";
// 端点:/api/v1/parent/v1/graphql双 /v1 前缀ARB-022 §24.4
function createTestClient(): Client {
return new Client({
url: "/api/v1/parent/v1/graphql",
exchanges: [cacheExchange, fetchExchange],
fetchOptions: { headers: { "X-Requested-With": "XMLHttpRequest" } },
});
}
function renderUseNotificationPreferences(parentId = "parent-001") {
const client = createTestClient();
const wrapper = ({ children }: { children: ReactNode }) => (
<UrqlProvider value={client}>{children}</UrqlProvider>
);
return renderHook(() => useNotificationPreferences(parentId), { wrapper });
}
// 构造一份本地偏好(用于 localStorage 预置)
function makeLocalPrefs(): NotificationPreferences {
return {
parentId: "parent-001",
preferences: {
"student-001": {
grade_recorded: { in_app: false, push: false },
},
},
defaults: {
grade_recorded: { in_app: true, push: true },
homework_graded: { in_app: true },
homework_assigned: { in_app: true },
exam_published: { in_app: true },
attendance_alert: { in_app: true },
school_announcement: { in_app: true },
teacher_message: { in_app: true },
fee_reminder: { in_app: true },
event_invitation: { in_app: true },
},
updatedAt: "2026-01-01T00:00:00Z",
};
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeEach(() => {
localStorage.clear();
});
describe("useNotificationPreferences", () => {
it("初始加载态preferences 为 null 且 loading=true", () => {
const { result } = renderUseNotificationPreferences();
expect(result.current.preferences).toBeNull();
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeUndefined();
});
it("localStorage 有缓存时优先用 localStorage 数据", async () => {
const local = makeLocalPrefs();
localStorage.setItem(PREFS_KEY, JSON.stringify(local));
const { result } = renderUseNotificationPreferences();
// useEffect 在挂载后立即读取 localStorage
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
expect(result.current.preferences?.parentId).toBe("parent-001");
// 验证用的是 localStorage 数据,而非 GraphQL mockmock 中 in_app=true
expect(
result.current.preferences?.preferences["student-001"]?.grade_recorded
?.in_app,
).toBe(false);
expect(result.current.loading).toBe(false);
});
it("localStorage 缓存无效 JSON 时降级到 GraphQL 数据", async () => {
localStorage.setItem(PREFS_KEY, "{invalid json");
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
// 来自 GraphQL mockgrade_recorded.in_app=true
expect(
result.current.preferences?.preferences["student-001"]?.grade_recorded
?.in_app,
).toBe(true);
});
it("无 localStorage 时使用 GraphQL 返回的默认偏好", async () => {
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
expect(result.current.preferences?.parentId).toBe("parent-001");
// 验证 mock 中两个子女的偏好均存在
expect(
result.current.preferences?.preferences["student-001"],
).toBeDefined();
expect(
result.current.preferences?.preferences["student-002"],
).toBeDefined();
expect(result.current.preferences?.defaults).toBeDefined();
});
it("updatePreference 更新本地 state 并写入 localStorage", async () => {
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
act(() => {
result.current.updatePreference(
"student-001",
"grade_recorded",
"in_app",
false,
);
});
// state 已更新
expect(
result.current.preferences?.preferences["student-001"]?.grade_recorded
?.in_app,
).toBe(false);
// localStorage 已写入
const stored = localStorage.getItem(PREFS_KEY);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!) as NotificationPreferences;
expect(parsed.preferences["student-001"]?.grade_recorded?.in_app).toBe(
false,
);
// updatedAt 被刷新
expect(parsed.updatedAt).not.toBe("2026-01-01T00:00:00Z");
});
it("updatePreference 触发 GraphQL mutationfire-and-forget", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
fetchSpy.mockClear();
act(() => {
result.current.updatePreference(
"student-001",
"homework_graded",
"push",
false,
);
});
// 等待 mutation 发出
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalled();
});
// 最后一次调用应为 mutation包含 updateNotificationPreferences
const lastCall = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1];
const body = (lastCall?.[1] as RequestInit | undefined)?.body;
expect(body).toBeDefined();
expect(String(body)).toContain("updateNotificationPreferences");
expect(String(body)).toContain("parent-001");
fetchSpy.mockRestore();
});
it("updatePreference mutation 失败时不抛出(静默降级)", async () => {
// 使用 server.use 覆盖 mutation 处理器,保留默认 query 处理器
server.use(
graphql.mutation("UpdateNotificationPreferences", () =>
HttpResponse.json(
{ errors: [{ message: "后端不可用" }] },
{ status: 200 },
),
),
);
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
// 不应抛出
expect(() => {
act(() => {
result.current.updatePreference(
"student-001",
"grade_recorded",
"in_app",
false,
);
});
}).not.toThrow();
// 本地 state 仍然更新成功localStorage 降级)
await waitFor(() => {
expect(
result.current.preferences?.preferences["student-001"]?.grade_recorded
?.in_app,
).toBe(false);
});
});
it("updatePreference 在 localPrefs 为 null 时为 no-op", async () => {
// 覆盖 query 返回 null data保留其他 handler
server.use(
graphql.query("MyNotificationPreferences", () =>
HttpResponse.json({ data: { myNotificationPreferences: null } }),
),
);
const { result } = renderUseNotificationPreferences();
// 等待查询完成,但 localPrefs 仍为 null因 data 为 null
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.preferences).toBeNull();
// 调用 updatePreference 不应抛出且不改变 null 状态
expect(() => {
act(() => {
result.current.updatePreference(
"student-001",
"grade_recorded",
"in_app",
true,
);
});
}).not.toThrow();
expect(result.current.preferences).toBeNull();
});
it("updatePreference 处理新 eventType不存在时创建", async () => {
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
act(() => {
result.current.updatePreference(
"student-001",
"exam_published",
"sms",
true,
);
});
expect(
result.current.preferences?.preferences["student-001"]?.exam_published
?.sms,
).toBe(true);
});
it("updatePreference 处理新 childId不存在时创建", async () => {
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
act(() => {
result.current.updatePreference(
"student-999",
"grade_recorded",
"in_app",
true,
);
});
expect(
result.current.preferences?.preferences["student-999"]?.grade_recorded
?.in_app,
).toBe(true);
});
it("GraphQL 查询出错时透传 error", async () => {
server.use(
graphql.query("MyNotificationPreferences", () =>
HttpResponse.json({ errors: [{ message: "未授权" }] }, { status: 200 }),
),
);
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
expect(result.current.error?.message).toContain("未授权");
// 出错时 localPrefs 保持 nullloading=false因 fetching=false
expect(result.current.preferences).toBeNull();
expect(result.current.loading).toBe(false);
});
it("loading 在 localPrefs 已加载后为 false即使 query 仍在 fetching", async () => {
const local = makeLocalPrefs();
localStorage.setItem(PREFS_KEY, JSON.stringify(local));
const { result } = renderUseNotificationPreferences();
await waitFor(() => {
expect(result.current.preferences).not.toBeNull();
});
// localPrefs 已就绪loading 必为 false
expect(result.current.loading).toBe(false);
});
});