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

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:
2026-07-19 17:17:11 +08:00
committed by auto-approve-bot
parent cb1b13ebf6
commit 91ef044e9c
10 changed files with 2319 additions and 2 deletions
+221 -2
View File
@@ -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()
})
})
})
+324
View File
@@ -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("/")
})
})