240 lines
8.0 KiB
TypeScript
240 lines
8.0 KiB
TypeScript
/**
|
||
* 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<unknown>;
|
||
}) {
|
||
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(
|
||
"品牌名称已存在"
|
||
);
|
||
});
|
||
});
|