test: 前端单测覆盖率Phase4,行覆盖率51%→62% (#589)
CI/CD Pipeline / Validate Code Quality And Tests (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 Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 8m14s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1413h59m4s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1413h59m4s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1413h59m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1414h8m3s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1414h10m14s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1414h10m16s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1414h10m18s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1414h10m18s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1414h10m18s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (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 Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 8m14s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1413h59m4s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1413h59m4s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1413h59m10s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1414h8m3s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1414h10m14s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1414h10m16s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1414h10m18s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1414h10m18s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1414h10m18s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #589.
This commit is contained in:
@@ -1,5 +1,36 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { normalizeUser } from "@/api/auth"
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
normalizeUser,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getCurrentUser,
|
||||
refreshAccessToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "@/api/auth"
|
||||
|
||||
const mockPost = vi.fn()
|
||||
const mockGet = vi.fn()
|
||||
const mockAxiosPost = vi.fn()
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
defaults: { baseURL: "/api/v1" },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("axios", () => ({
|
||||
default: {
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
},
|
||||
post: (...args: unknown[]) => mockAxiosPost(...args),
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
describe("normalizeUser", () => {
|
||||
it("normalizes canonical API current-user fields", () => {
|
||||
@@ -44,4 +75,192 @@ describe("normalizeUser", () => {
|
||||
created_at: "2026-06-22T00:00:00Z",
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers id over user_id when both present", () => {
|
||||
const result = normalizeUser({
|
||||
id: "id-first",
|
||||
user_id: "userid-second",
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.id).toBe("id-first")
|
||||
expect(result.user_id).toBe("id-first")
|
||||
})
|
||||
|
||||
it("prefers is_email_verified over email_verified", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
is_email_verified: true,
|
||||
email_verified: false,
|
||||
})
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
expect(result.email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("defaults email verified to false when both missing", () => {
|
||||
const result = normalizeUser({
|
||||
email: "test@test.com",
|
||||
username: "test",
|
||||
display_name: "Test",
|
||||
})
|
||||
expect(result.is_email_verified).toBe(false)
|
||||
expect(result.email_verified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("auth API functions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPost.mockResolvedValue({ data: { success: true } })
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
mockAxiosPost.mockResolvedValue({ data: { access_token: "tok" } })
|
||||
})
|
||||
|
||||
describe("login", () => {
|
||||
it("calls login API with correct params", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
data: { access_token: "acc", refresh_token: "ref", user_id: "1" },
|
||||
})
|
||||
const result = await login({ email: "test@test.com", password: "pass" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/login", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
})
|
||||
expect(result.access_token).toBe("acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("login failed"))
|
||||
await expect(login({ email: "t", password: "p" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("register", () => {
|
||||
it("calls register API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "ok" } })
|
||||
const result = await register({
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/register", {
|
||||
email: "test@test.com",
|
||||
password: "pass",
|
||||
username: "testuser",
|
||||
})
|
||||
expect(result.message).toBe("ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("register failed"))
|
||||
await expect(register({ email: "t", password: "p", username: "u" })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logout", () => {
|
||||
it("calls logout API", async () => {
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
await logout()
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/logout")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("logout failed"))
|
||||
await expect(logout()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getCurrentUser", () => {
|
||||
it("fetches and normalizes user", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
user_id: "u1",
|
||||
email: "user@test.com",
|
||||
username: "user1",
|
||||
display_name: "User One",
|
||||
email_verified: true,
|
||||
},
|
||||
})
|
||||
const result = await getCurrentUser()
|
||||
expect(mockGet).toHaveBeenCalledWith("/auth/me")
|
||||
expect(result.id).toBe("u1")
|
||||
expect(result.email).toBe("user@test.com")
|
||||
expect(result.is_email_verified).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("fetch failed"))
|
||||
await expect(getCurrentUser()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("refreshAccessToken", () => {
|
||||
it("calls refresh endpoint with raw axios", async () => {
|
||||
mockAxiosPost.mockResolvedValue({
|
||||
data: { access_token: "new-acc", refresh_token: "new-ref" },
|
||||
})
|
||||
const result = await refreshAccessToken("old-refresh")
|
||||
expect(mockAxiosPost).toHaveBeenCalledWith("/api/v1/auth/refresh", {
|
||||
refresh_token: "old-refresh",
|
||||
})
|
||||
expect(result.access_token).toBe("new-acc")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockAxiosPost.mockRejectedValue(new Error("refresh failed"))
|
||||
await expect(refreshAccessToken("tok")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("requestPasswordReset", () => {
|
||||
it("calls forgot-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "sent" } })
|
||||
const result = await requestPasswordReset("test@test.com")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/forgot-password", {
|
||||
email: "test@test.com",
|
||||
})
|
||||
expect(result.message).toBe("sent")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(requestPasswordReset("e")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetPassword", () => {
|
||||
it("calls reset-password API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "reset ok" } })
|
||||
const result = await resetPassword("token123", "newpass")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/reset-password", {
|
||||
token: "token123",
|
||||
new_password: "newpass",
|
||||
})
|
||||
expect(result.message).toBe("reset ok")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("failed"))
|
||||
await expect(resetPassword("t", "p")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("verifyEmail", () => {
|
||||
it("calls verify-email API", async () => {
|
||||
mockPost.mockResolvedValue({ data: { message: "verified" } })
|
||||
const result = await verifyEmail("verify-token")
|
||||
expect(mockPost).toHaveBeenCalledWith("/auth/verify-email", {
|
||||
token: "verify-token",
|
||||
})
|
||||
expect(result.message).toBe("verified")
|
||||
})
|
||||
|
||||
it("rejects on error", async () => {
|
||||
mockPost.mockRejectedValue(new Error("verify failed"))
|
||||
await expect(verifyEmail("t")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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(),
|
||||
}))
|
||||
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "@/api/auth"
|
||||
import apiClient from "@/api/client"
|
||||
|
||||
// 从真实实例取出拦截器回调
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{
|
||||
fulfilled: (config: unknown) => unknown
|
||||
rejected: (error: unknown) => unknown
|
||||
}>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} 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 setAuth", async () => {
|
||||
const mockSetAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: mockSetAuth,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockResolvedValue({
|
||||
access_token: "new-access",
|
||||
refresh_token: "new-refresh",
|
||||
} as never)
|
||||
|
||||
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
|
||||
// 但我们只关心刷新逻辑是否正确执行
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// 重试会因为没有真实网络而失败,忽略
|
||||
}
|
||||
|
||||
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles refresh failure by logging out", async () => {
|
||||
const mockClearAuth = vi.fn()
|
||||
vi.mocked(useAuthStore.getState).mockReturnValue({
|
||||
user: { id: "1", email: "test@test.com" },
|
||||
accessToken: "old-access",
|
||||
refreshToken: "old-refresh",
|
||||
isAuthenticated: true,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
|
||||
try {
|
||||
await responseErrorInterceptor(err)
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(mockClearAuth).toHaveBeenCalled()
|
||||
expect(window.location.href).toBe("/")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user