// 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 }) => ( {children} ); 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 mock(mock 中 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 mock:grade_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 mutation(fire-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 保持 null,loading=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); }); });