315 lines
9.1 KiB
TypeScript
315 lines
9.1 KiB
TypeScript
/**
|
||
* 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: "更新品牌" }),
|
||
});
|
||
});
|
||
});
|