geo/frontend/__tests__/lib/api/client.test.ts

240 lines
8.0 KiB
TypeScript
Raw 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.

/**
* 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(
"品牌名称已存在"
);
});
});