/** * API Client (fetchWithAuth) 单元测试 * * 覆盖:带 token 请求、错误处理(401/500/204) */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { fetchWithAuth, API_BASE, getApiUrl } from "@/lib/api/client"; // ── Mock next-auth/react ────────────────────────────────────────────────────── vi.mock("next-auth/react", () => ({ getSession: vi.fn(() => Promise.resolve({ accessToken: "session-token-123" }) ), })); import { getSession } from "next-auth/react"; const mockGetSession = vi.mocked(getSession); // ── Mock global fetch ───────────────────────────────────────────────────────── const mockFetch = vi.fn(); const originalFetch = global.fetch; beforeEach(() => { global.fetch = mockFetch; vi.clearAllMocks(); }); afterEach(() => { global.fetch = originalFetch; }); // ── 辅助 ────────────────────────────────────────────────────────────────────── function mockFetchResponse(options: { ok: boolean; status: number; json?: () => Promise; }) { return { ok: options.ok, status: options.status, json: options.json ?? (() => Promise.resolve({})), }; } // ── getApiUrl ───────────────────────────────────────────────────────────────── describe("getApiUrl", () => { it("应拼接 API_BASE 和路径", () => { const url = getApiUrl("/api/v1/brands"); expect(url).toBe(`${API_BASE}/api/v1/brands`); }); }); // ── fetchWithAuth ───────────────────────────────────────────────────────────── describe("fetchWithAuth", () => { it("显式传入 token 时应添加 Authorization 头", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) }) ); await fetchWithAuth("/api/test", {}, "my-secret-token"); const [url, options] = mockFetch.mock.calls[0]; expect(url).toBe(`${API_BASE}/api/test`); expect(options.headers.Authorization).toBe("Bearer my-secret-token"); expect(options.headers["Content-Type"]).toBe("application/json"); }); it("未传入 token 且在浏览器环境下应从 session 获取", async () => { // 模拟浏览器环境 const originalWindow = global.window; Object.defineProperty(global, "window", { value: { location: {} }, writable: true, }); mockGetSession.mockResolvedValueOnce({ accessToken: "session-token-123", }); mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) }) ); await fetchWithAuth("/api/test", {}); const options = mockFetch.mock.calls[0][1]; expect(options.headers.Authorization).toBe("Bearer session-token-123"); Object.defineProperty(global, "window", { value: originalWindow, writable: true, }); }); it("session 无 accessToken 时不应添加 Authorization 头", async () => { // jsdom 环境下 typeof window !== "undefined",所以会走 session 路径 mockGetSession.mockResolvedValueOnce({}); // session 没有 accessToken mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) }) ); await fetchWithAuth("/api/test", {}); const options = mockFetch.mock.calls[0][1]; expect(options.headers.Authorization).toBeUndefined(); }); it("session 获取失败时不应添加 Authorization 头", async () => { mockGetSession.mockRejectedValueOnce(new Error("session error")); mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) }) ); await fetchWithAuth("/api/test", {}); const options = mockFetch.mock.calls[0][1]; expect(options.headers.Authorization).toBeUndefined(); }); it("应合并自定义 headers", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({}) }) ); await fetchWithAuth("/api/test", { headers: { "X-Custom": "value" }, }, "token"); const options = mockFetch.mock.calls[0][1]; expect(options.headers["Content-Type"]).toBe("application/json"); expect(options.headers["X-Custom"]).toBe("value"); expect(options.headers.Authorization).toBe("Bearer token"); }); // ── 401 错误 ─────────────────────────────────────────────────────────── it("401 应抛出'登录已过期'错误", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: false, status: 401 }) ); await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow( "登录已过期,请重新登录" ); }); // ── 500 错误 ─────────────────────────────────────────────────────────── it("500 错误应抛出包含状态码的错误", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: false, status: 500, json: () => Promise.resolve({ detail: "内部服务器错误" }), }) ); await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow( "内部服务器错误" ); }); it("500 错误且 JSON 解析失败应使用默认错误信息", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: false, status: 500, json: () => Promise.reject(new Error("invalid json")), }) ); await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow( "请求失败 (HTTP 500)" ); }); it("其他错误状态码应正确抛出", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: false, status: 422, json: () => Promise.resolve({ detail: "参数校验失败" }), }) ); await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow( "参数校验失败" ); }); // ── 成功响应 ──────────────────────────────────────────────────────────── it("200 应返回 JSON 数据", async () => { const mockData = { id: "1", name: "品牌" }; mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve(mockData), }) ); const result = await fetchWithAuth("/api/test", {}, "token"); expect(result).toEqual(mockData); }); it("204 应返回 null", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: true, status: 204 }) ); const result = await fetchWithAuth("/api/test", {}, "token"); expect(result).toBeNull(); }); // ── 错误响应的 message 字段 ──────────────────────────────────────────── it("错误响应包含 message 字段时应优先使用", async () => { mockFetch.mockResolvedValueOnce( mockFetchResponse({ ok: false, status: 400, json: () => Promise.resolve({ message: "品牌名称已存在" }), }) ); await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow( "品牌名称已存在" ); }); });