82e05fcdbb
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
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<unknown>
|
|
}>
|
|
|
|
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<string, string>; _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<never>).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<never>).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<never>).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<never>).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<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("参数错误")
|
|
})
|
|
|
|
it("shows message field", async () => {
|
|
const err = makeAxiosError(400, { message: "操作失败" })
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("操作失败")
|
|
})
|
|
|
|
it("shows msg field", async () => {
|
|
const err = makeAxiosError(400, { msg: "出错了" })
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("出错了")
|
|
})
|
|
|
|
it("handles nested message object", async () => {
|
|
const err = makeAxiosError(400, { message: { message: "深层错误" } })
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("深层错误")
|
|
})
|
|
|
|
it("handles nested msg object", async () => {
|
|
const err = makeAxiosError(400, { msg: { msg: "嵌套错误" } })
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).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<never>).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<string, string> }
|
|
response: { status: number; data: { detail: string } }
|
|
message: string
|
|
__msgShown?: boolean
|
|
}
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).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<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("文件过大,请缩小后重试")
|
|
})
|
|
|
|
it("shows unsupported format for 415", async () => {
|
|
const err = makeAxiosError(415, {})
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("不支持的文件格式")
|
|
})
|
|
|
|
it("shows service unavailable for 503", async () => {
|
|
const err = makeAxiosError(503, {})
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("服务暂不可用,请稍后再试")
|
|
})
|
|
|
|
it("shows server busy for 500", async () => {
|
|
const err = makeAxiosError(500, {})
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
|
|
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
|
|
})
|
|
|
|
it("shows server busy for 502", async () => {
|
|
const err = makeAxiosError(502, {})
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).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<never>).rejects.toThrow()
|
|
expect(message.error).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("no __msgShown for unhandled 4xx", async () => {
|
|
const err = makeAxiosError(403, {}) as {
|
|
config: { headers: Record<string, string> }
|
|
response: { status: number; data: Record<string, never> }
|
|
message: string
|
|
__msgShown?: boolean
|
|
}
|
|
await expect(responseErrorInterceptor(err) as Promise<never>).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<never>).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<never>).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<never>).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<never>).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")
|
|
})
|
|
})
|