geo/frontend/__tests__/hooks/use-api.test.ts

315 lines
9.1 KiB
TypeScript
Raw Permalink 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.

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