import { describe, expect, it, vi, beforeEach } from "vitest" vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() }, })) vi.mock("@/store/authStore", () => ({ useAuthStore: { getState: vi.fn(() => ({ user: { id: "1", email: "test@test.com" }, accessToken: "old-access", refreshToken: "old-refresh", clearAuth: vi.fn(), setAuth: vi.fn(), })), }, })) vi.mock("@/api/auth", () => ({ refreshAccessToken: vi.fn(), })) vi.mock("@/api/auth/tokenRefresh", () => ({ scheduleProactiveRefresh: vi.fn(), cancelProactiveRefresh: vi.fn(), executeTokenRefresh: vi.fn(), })) import { message } from "antd" import { useAuthStore } from "@/store/authStore" import { refreshAccessToken } from "@/api/auth" import { executeTokenRefresh } from "@/api/auth/tokenRefresh" import apiClient from "@/api/client" // 从真实实例取出拦截器回调 const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{ fulfilled: (config: unknown) => unknown rejected: (error: unknown) => unknown }> const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{ fulfilled: (response: unknown) => unknown rejected: (error: unknown) => Promise }> const requestInterceptor = requestHandlers[0]?.fulfilled! const requestErrorInterceptor = requestHandlers[0]?.rejected! const responseInterceptor = responseHandlers[0]?.fulfilled! const responseErrorInterceptor = responseHandlers[0]?.rejected! function makeAxiosError(status?: number, data?: unknown, code?: string, hasResponse = true) { const err = { config: { headers: {} }, message: "error", } as { config: { headers: Record; _retry?: boolean; url?: string } response?: { status: number; data: unknown } code?: string message: string } if (hasResponse && status !== undefined) { err.response = { status, data } } if (code) err.code = code return err } describe("apiClient", () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() Object.defineProperty(window, "location", { value: { href: "" }, writable: true, }) }) describe("request interceptor", () => { it("adds Authorization header when token exists", () => { localStorage.setItem("access_token", "test-token") const config = { headers: {} } const result = requestInterceptor(config) as { headers: { Authorization?: string } } expect(result.headers.Authorization).toBe("Bearer test-token") }) it("skips Authorization header when no token", () => { const config = { headers: {} } const result = requestInterceptor(config) as { headers: { Authorization?: string } } expect(result.headers.Authorization).toBeUndefined() }) it("rejects on request error", async () => { const error = new Error("request error") await expect(requestErrorInterceptor(error) as Promise).rejects.toThrow( "request error", ) }) }) describe("response interceptor - success", () => { it("passes through successful response", () => { const response = { data: { success: true }, status: 200 } expect(responseInterceptor(response)).toBe(response) }) }) describe("response interceptor - timeout & network", () => { it("shows timeout message for ECONNABORTED", async () => { const err = makeAxiosError(undefined, undefined, "ECONNABORTED") await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试") }) it("shows timeout message for timeout string", async () => { const err = { ...makeAxiosError(), message: "timeout of 10000ms exceeded" } await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试") }) it("shows network error when no response", async () => { const err = makeAxiosError(undefined, undefined, undefined, false) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("网络连接异常,请检查网络设置") }) }) describe("response interceptor - server error messages", () => { it("shows detail field", async () => { const err = makeAxiosError(400, { detail: "参数错误" }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("参数错误") }) it("shows message field", async () => { const err = makeAxiosError(400, { message: "操作失败" }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("操作失败") }) it("shows msg field", async () => { const err = makeAxiosError(400, { msg: "出错了" }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("出错了") }) it("handles nested message object", async () => { const err = makeAxiosError(400, { message: { message: "深层错误" } }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("深层错误") }) it("handles nested msg object", async () => { const err = makeAxiosError(400, { msg: { msg: "嵌套错误" } }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("嵌套错误") }) it("stringifies object with no string fields", async () => { const err = makeAxiosError(400, { detail: { code: 123, foo: "bar" } }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith('{"code":123,"foo":"bar"}') }) it("marks __msgShown when message displayed", async () => { const err = makeAxiosError(400, { detail: "test" }) as { config: { headers: Record } response: { status: number; data: { detail: string } } message: string __msgShown?: boolean } await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(err.__msgShown).toBe(true) }) }) describe("response interceptor - HTTP status codes", () => { it("shows file too large for 413", async () => { const err = makeAxiosError(413, {}) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("文件过大,请缩小后重试") }) it("shows unsupported format for 415", async () => { const err = makeAxiosError(415, {}) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("不支持的文件格式") }) it("shows service unavailable for 503", async () => { const err = makeAxiosError(503, {}) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("服务暂不可用,请稍后再试") }) it("shows server busy for 500", async () => { const err = makeAxiosError(500, {}) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试") }) it("shows server busy for 502", async () => { const err = makeAxiosError(502, {}) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试") }) it("no message for 4xx without server msg", async () => { const err = makeAxiosError(403, {}) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).not.toHaveBeenCalled() }) it("no __msgShown for unhandled 4xx", async () => { const err = makeAxiosError(403, {}) as { config: { headers: Record } response: { status: number; data: Record } message: string __msgShown?: boolean } await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(err.__msgShown).toBeUndefined() }) }) describe("safeExtractString edge cases", () => { it("returns empty string for numeric message", async () => { const err = makeAxiosError(400, { message: 123 }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).not.toHaveBeenCalled() }) it("returns empty string for null data", async () => { const err = makeAxiosError(400, null) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).not.toHaveBeenCalled() }) it("handles detail with nested detail object", async () => { const err = makeAxiosError(400, { detail: { detail: "nested detail" } }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(message.error).toHaveBeenCalledWith("nested detail") }) }) }) describe("apiClient - 401 token refresh", () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() localStorage.setItem("access_token", "old-access") localStorage.setItem("refresh_token", "old-refresh") Object.defineProperty(window, "location", { value: { href: "" }, writable: true, }) }) it("logs out when no refresh token on 401", async () => { const mockClearAuth = vi.fn() vi.mocked(useAuthStore.getState).mockReturnValue({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, clearAuth: mockClearAuth, setAuth: vi.fn(), } as any) const err = makeAxiosError(401, { detail: "Unauthorized" }) await expect(responseErrorInterceptor(err) as Promise).rejects.toThrow() expect(mockClearAuth).toHaveBeenCalled() expect(window.location.href).toBe("/") }) it("refreshes token on 401 and calls executeTokenRefresh", async () => { const mockSetAuth = vi.fn() let currentAccessToken = "old-access" vi.mocked(useAuthStore.getState).mockImplementation(() => ({ user: { id: "1", email: "test@test.com" }, accessToken: currentAccessToken, refreshToken: "old-refresh", isAuthenticated: true, clearAuth: vi.fn(), setAuth: ((_user: any, newAccess: string, _newRefresh: string) => { currentAccessToken = newAccess mockSetAuth(_user, newAccess, _newRefresh) }) as any, })) // Mock executeTokenRefresh to simulate successful refresh vi.mocked(executeTokenRefresh).mockImplementation(() => { currentAccessToken = "new-access" mockSetAuth({ id: "1", email: "test@test.com" }, "new-access", "new-refresh") return Promise.resolve() }) const err = makeAxiosError(401, { detail: "Unauthorized" }) try { await responseErrorInterceptor(err) } catch { // 重试会因为没有真实网络而失败,忽略 } expect(executeTokenRefresh).toHaveBeenCalled() expect(mockSetAuth).toHaveBeenCalled() }) it("handles refresh failure by logging out", async () => { const mockClearAuth = vi.fn() // After executeTokenRefresh fails, it clears auth (sets accessToken to null) // and redirects to /login. The promise resolves (doesn't reject). let currentAccessToken: string | null = "old-access" vi.mocked(useAuthStore.getState).mockImplementation(() => ({ user: { id: "1", email: "test@test.com" }, accessToken: currentAccessToken, refreshToken: "old-refresh", isAuthenticated: currentAccessToken !== null, clearAuth: (() => { currentAccessToken = null mockClearAuth() window.location.href = "/login" }) as any, setAuth: vi.fn(), })) // Mock executeTokenRefresh: simulates failure → clears auth + redirects vi.mocked(executeTokenRefresh).mockImplementation(() => { currentAccessToken = null mockClearAuth() window.location.href = "/login" return Promise.resolve() }) const err = makeAxiosError(401, { detail: "Unauthorized" }) try { await responseErrorInterceptor(err) } catch { // expected - rejects because accessToken is null after failed refresh } expect(executeTokenRefresh).toHaveBeenCalled() expect(mockClearAuth).toHaveBeenCalled() expect(window.location.href).toBe("/login") }) })