/** * useApi / usePaginatedApi / useApiMutation Hooks 单元测试 * * 覆盖:加载/成功/错误状态、分页参数、mutation trigger */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import useSWR from "swr"; import { useApi, usePaginatedApi, useApiMutation, swrFetcher, } from "@/lib/hooks/use-api"; import type { PaginatedResponse } from "@/lib/hooks/use-api"; // ── Mock fetchWithAuth ──────────────────────────────────────────────────────── vi.mock("@/lib/api/client", () => ({ fetchWithAuth: vi.fn(), })); import { fetchWithAuth } from "@/lib/api/client"; const mockFetchWithAuth = vi.mocked(fetchWithAuth); // ── swrFetcher ──────────────────────────────────────────────────────────────── describe("swrFetcher", () => { it("应调用 fetchWithAuth 并返回结果", async () => { mockFetchWithAuth.mockResolvedValueOnce({ data: "test" }); const result = await swrFetcher("/api/test"); expect(result).toEqual({ data: "test" }); expect(mockFetchWithAuth).toHaveBeenCalledWith("/api/test"); }); }); // ── useApi ──────────────────────────────────────────────────────────────────── describe("useApi", () => { beforeEach(() => { vi.clearAllMocks(); // 清除 SWR 缓存,避免测试间数据残留 useSWR.clearCache?.(); }); it("url 为 null 时应暂停请求", () => { const { result } = renderHook(() => useApi(null)); expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); expect(mockFetchWithAuth).not.toHaveBeenCalled(); }); it("请求成功应返回数据", async () => { const mockData = { items: [{ id: "1", name: "品牌" }], total: 1 }; mockFetchWithAuth.mockResolvedValueOnce(mockData); const { result } = renderHook(() => useApi("/api/brands-success")); await waitFor(() => { expect(result.current.data).toEqual(mockData); expect(result.current.error).toBeUndefined(); expect(result.current.isLoading).toBe(false); }); }); it("请求失败应返回错误", async () => { mockFetchWithAuth.mockRejectedValueOnce(new Error("网络错误")); const { result } = renderHook(() => useApi("/api/brands-error")); await waitFor(() => { expect(result.current.error).toBeInstanceOf(Error); expect(result.current.error?.message).toBe("网络错误"); }); }); it("refresh 应触发重新获取", async () => { mockFetchWithAuth.mockResolvedValue({ items: [] }); const { result } = renderHook(() => useApi("/api/brands-refresh")); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); const callCount = mockFetchWithAuth.mock.calls.length; act(() => { result.current.refresh(); }); await waitFor(() => { expect(mockFetchWithAuth.mock.calls.length).toBeGreaterThan(callCount); }); }); }); // ── usePaginatedApi ─────────────────────────────────────────────────────────── describe("usePaginatedApi", () => { beforeEach(() => { vi.clearAllMocks(); useSWR.clearCache?.(); }); it("应正确构造分页 URL", async () => { const mockResponse: PaginatedResponse<{ id: string }> = { items: [{ id: "1" }], total: 10, }; mockFetchWithAuth.mockResolvedValueOnce(mockResponse); renderHook(() => usePaginatedApi("/api/brands-page1", { page: 1, pageSize: 10 }) ); await waitFor(() => { // page=1, pageSize=10 → offset=0, limit=10 expect(mockFetchWithAuth).toHaveBeenCalledWith( "/api/brands-page1?limit=10&offset=0" ); }); }); it("第2页应正确计算 offset", async () => { const mockResponse: PaginatedResponse<{ id: string }> = { items: [], total: 20, }; mockFetchWithAuth.mockResolvedValueOnce(mockResponse); renderHook(() => usePaginatedApi("/api/brands-page2", { page: 2, pageSize: 5 }) ); await waitFor(() => { // page=2, pageSize=5 → offset=5, limit=5 expect(mockFetchWithAuth).toHaveBeenCalledWith( "/api/brands-page2?limit=5&offset=5" ); }); }); it("baseUrl 为 null 时应暂停请求", () => { const { result } = renderHook(() => usePaginatedApi(null, { page: 1, pageSize: 10 }) ); expect(result.current.isLoading).toBe(false); expect(result.current.data).toBeUndefined(); }); it("应返回 total 和分页信息", async () => { const mockResponse: PaginatedResponse<{ id: string }> = { items: [{ id: "1" }, { id: "2" }], total: 25, }; mockFetchWithAuth.mockResolvedValueOnce(mockResponse); const { result } = renderHook(() => usePaginatedApi("/api/brands-total", { page: 1, pageSize: 10 }) ); await waitFor(() => { expect(result.current.data).toEqual([{ id: "1" }, { id: "2" }]); expect(result.current.total).toBe(25); expect(result.current.page).toBe(1); expect(result.current.pageSize).toBe(10); }); }); it("setPage 应更新页码", async () => { mockFetchWithAuth.mockResolvedValue({ items: [], total: 30, }); const { result } = renderHook(() => usePaginatedApi("/api/brands-setpage", { page: 1, pageSize: 10 }) ); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); act(() => { result.current.setPage(3); }); await waitFor(() => { expect(result.current.page).toBe(3); }); }); it("数据未加载时 total 应为 0", () => { // 用 null URL 确保无请求发出,total 为 0 const { result } = renderHook(() => usePaginatedApi(null, { page: 1, pageSize: 10 }) ); expect(result.current.total).toBe(0); }); }); // ── useApiMutation ──────────────────────────────────────────────────────────── describe("useApiMutation", () => { beforeEach(() => { vi.clearAllMocks(); useSWR.clearCache?.(); }); it("trigger 成功应返回数据", async () => { const mockResult = { id: "1", name: "新品牌" }; mockFetchWithAuth.mockResolvedValueOnce(mockResult); const { result } = renderHook(() => useApiMutation("/api/brands", "POST") ); let response: unknown; await act(async () => { response = await result.current.trigger({ name: "新品牌" }); }); expect(response).toEqual(mockResult); expect(result.current.isMutating).toBe(false); expect(result.current.error).toBeUndefined(); }); it("trigger 失败应返回 null 并设置 error", async () => { mockFetchWithAuth.mockRejectedValueOnce(new Error("服务器错误")); const { result } = renderHook(() => useApiMutation("/api/brands", "POST") ); let response: unknown; await act(async () => { response = await result.current.trigger({ name: "失败" }); }); expect(response).toBeNull(); expect(result.current.error).toBeInstanceOf(Error); expect(result.current.error?.message).toBe("服务器错误"); expect(result.current.isMutating).toBe(false); }); it("trigger 过程中 isMutating 应为 true", async () => { let resolveMutation: (value: unknown) => void; mockFetchWithAuth.mockReturnValueOnce( new Promise((resolve) => { resolveMutation = resolve; }) ); const { result } = renderHook(() => useApiMutation("/api/brands", "PUT") ); act(() => { result.current.trigger({ name: "更新" }); }); await waitFor(() => { expect(result.current.isMutating).toBe(true); }); resolveMutation!({ id: "1" }); await waitFor(() => { expect(result.current.isMutating).toBe(false); }); }); it("reset 应清除错误状态", async () => { mockFetchWithAuth.mockRejectedValueOnce(new Error("出错了")); const { result } = renderHook(() => useApiMutation("/api/brands", "DELETE") ); await act(async () => { await result.current.trigger(); }); expect(result.current.error).toBeDefined(); act(() => { result.current.reset(); }); expect(result.current.error).toBeUndefined(); }); it("应正确传递 method 和 body", async () => { mockFetchWithAuth.mockResolvedValueOnce({ success: true }); const { result } = renderHook(() => useApiMutation("/api/brands/1", "PUT") ); await act(async () => { await result.current.trigger({ name: "更新品牌" }); }); expect(mockFetchWithAuth).toHaveBeenCalledWith("/api/brands/1", { method: "PUT", body: JSON.stringify({ name: "更新品牌" }), }); }); });