test: 完善自动化测试体系(7个模块,73个任务)
系统性地修复、补充和强化项目的自动化测试能力: 1. 测试基础设施修复 - 修复 stubConcurrencyCache 缺失方法和构造函数参数不匹配 - 创建 testutil 共享包(stubs.go, fixtures.go, httptest.go) - 为所有 Stub 添加编译期接口断言 2. 中间件测试补充 - 新增 JWT 认证中间件测试(有效/过期/篡改/缺失 Token) - 补充 rate_limiter 和 recovery 中间件测试场景 3. 网关核心路径测试 - 新增账户选择、等待队列、流式响应、并发控制、计费、Claude Code 检测测试 - 覆盖负载均衡、粘性会话、SSE 转发、槽位管理等关键逻辑 4. 前端测试体系(11个新测试文件,163个测试用例) - Pinia stores: auth, app, subscriptions - API client: 请求拦截器、响应拦截器、401 刷新 - Router guards: 认证重定向、管理员权限、简易模式限制 - Composables: useForm, useTableLoader, useClipboard - Components: LoginForm, ApiKeyCreate, Dashboard 5. CI/CD 流水线重构 - 重构 backend-ci.yml 为统一的 ci.yml - 前后端 4 个并行 Job + Postgres/Redis services - Race 检测、覆盖率收集与门禁、Docker 构建验证 6. E2E 自动化测试 - e2e-test.sh 自动化脚本(Docker 启动→健康检查→测试→清理) - 用户注册→登录→API Key→网关调用完整链路测试 - Mock 模式和 API Key 脱敏支持 7. 修复预存问题 - tlsfingerprint dialer_test.go 缺失 build tag 导致集成测试编译冲突 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
137
frontend/src/composables/__tests__/useClipboard.spec.ts
Normal file
137
frontend/src/composables/__tests__/useClipboard.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock app store
|
||||
const mockShowSuccess = vi.fn()
|
||||
const mockShowError = vi.fn()
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mockShowSuccess,
|
||||
showError: mockShowError,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
|
||||
describe('useClipboard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
|
||||
// 默认模拟安全上下文 + Clipboard API
|
||||
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
// 恢复 execCommand
|
||||
if ('execCommand' in document) {
|
||||
delete (document as any).execCommand
|
||||
}
|
||||
})
|
||||
|
||||
it('复制成功后 copied 变为 true', async () => {
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
expect(copied.value).toBe(false)
|
||||
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(copied.value).toBe(true)
|
||||
})
|
||||
|
||||
it('copied 在 2 秒后自动恢复为 false', async () => {
|
||||
const { copied, copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello')
|
||||
expect(copied.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(2000)
|
||||
|
||||
expect(copied.value).toBe(false)
|
||||
})
|
||||
|
||||
it('复制成功时调用 showSuccess', async () => {
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello', '已复制')
|
||||
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith('已复制')
|
||||
})
|
||||
|
||||
it('无自定义消息时使用 i18n 默认消息', async () => {
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockShowSuccess).toHaveBeenCalledWith('common.copiedToClipboard')
|
||||
})
|
||||
|
||||
it('空文本返回 false 且不复制', async () => {
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
|
||||
const result = await copyToClipboard('')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(copied.value).toBe(false)
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Clipboard API 失败时降级到 fallback', async () => {
|
||||
;(navigator.clipboard.writeText as any).mockRejectedValue(new Error('API failed'))
|
||||
|
||||
// jsdom 没有 execCommand,手动定义
|
||||
;(document as any).execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('fallback text')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(copied.value).toBe(true)
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('非安全上下文使用 fallback', async () => {
|
||||
Object.defineProperty(window, 'isSecureContext', { value: false, writable: true })
|
||||
|
||||
;(document as any).execCommand = vi.fn().mockReturnValue(true)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('insecure context text')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(copied.value).toBe(true)
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('所有复制方式均失败时调用 showError', async () => {
|
||||
;(navigator.clipboard.writeText as any).mockRejectedValue(new Error('fail'))
|
||||
;(document as any).execCommand = vi.fn().mockReturnValue(false)
|
||||
|
||||
const { copyToClipboard, copied } = useClipboard()
|
||||
const result = await copyToClipboard('text')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(copied.value).toBe(false)
|
||||
expect(mockShowError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
143
frontend/src/composables/__tests__/useForm.spec.ts
Normal file
143
frontend/src/composables/__tests__/useForm.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useForm } from '@/composables/useForm'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
// Mock API 依赖(app store 内部引用了这些)
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useForm', () => {
|
||||
let appStore: ReturnType<typeof useAppStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appStore = useAppStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('submit 期间 loading 为 true,完成后为 false', async () => {
|
||||
let resolveSubmit: () => void
|
||||
const submitFn = vi.fn(
|
||||
() => new Promise<void>((resolve) => { resolveSubmit = resolve })
|
||||
)
|
||||
|
||||
const { loading, submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
|
||||
const submitPromise = submit()
|
||||
// 提交中
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveSubmit!()
|
||||
await submitPromise
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('submit 成功时显示成功消息', async () => {
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
const showSuccessSpy = vi.spyOn(appStore, 'showSuccess')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
successMsg: '保存成功',
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(showSuccessSpy).toHaveBeenCalledWith('保存成功')
|
||||
})
|
||||
|
||||
it('submit 成功但无 successMsg 时不调用 showSuccess', async () => {
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
const showSuccessSpy = vi.spyOn(appStore, 'showSuccess')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(showSuccessSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submit 失败时显示错误消息并抛出错误', async () => {
|
||||
const error = Object.assign(new Error('提交失败'), {
|
||||
response: { data: { message: '服务器错误' } },
|
||||
})
|
||||
const submitFn = vi.fn().mockRejectedValue(error)
|
||||
const showErrorSpy = vi.spyOn(appStore, 'showError')
|
||||
|
||||
const { submit, loading } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await expect(submit()).rejects.toThrow('提交失败')
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalled()
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('submit 失败时使用自定义 errorMsg', async () => {
|
||||
const submitFn = vi.fn().mockRejectedValue(new Error('network'))
|
||||
const showErrorSpy = vi.spyOn(appStore, 'showError')
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
errorMsg: '自定义错误提示',
|
||||
})
|
||||
|
||||
await expect(submit()).rejects.toThrow()
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalledWith('自定义错误提示')
|
||||
})
|
||||
|
||||
it('loading 中不会重复提交', async () => {
|
||||
let resolveSubmit: () => void
|
||||
const submitFn = vi.fn(
|
||||
() => new Promise<void>((resolve) => { resolveSubmit = resolve })
|
||||
)
|
||||
|
||||
const { submit } = useForm({
|
||||
form: { name: 'test' },
|
||||
submitFn,
|
||||
})
|
||||
|
||||
// 第一次提交
|
||||
const p1 = submit()
|
||||
// 第二次提交(应被忽略,因为 loading=true)
|
||||
submit()
|
||||
|
||||
expect(submitFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveSubmit!()
|
||||
await p1
|
||||
})
|
||||
|
||||
it('传递 form 数据到 submitFn', async () => {
|
||||
const formData = { name: 'test', email: 'test@example.com' }
|
||||
const submitFn = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const { submit } = useForm({
|
||||
form: formData,
|
||||
submitFn,
|
||||
})
|
||||
|
||||
await submit()
|
||||
|
||||
expect(submitFn).toHaveBeenCalledWith(formData)
|
||||
})
|
||||
})
|
||||
252
frontend/src/composables/__tests__/useTableLoader.spec.ts
Normal file
252
frontend/src/composables/__tests__/useTableLoader.spec.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { useTableLoader } from '@/composables/useTableLoader'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
// Mock @vueuse/core 的 useDebounceFn
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDebounceFn: (fn: Function, ms: number) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const debounced = (...args: any[]) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => fn(...args), ms)
|
||||
}
|
||||
debounced.cancel = () => { if (timer) clearTimeout(timer) }
|
||||
return debounced
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Vue 的 onUnmounted(composable 外使用时会报错)
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
onUnmounted: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const createMockFetchFn = (items: any[] = [], total = 0, pages = 1) => {
|
||||
return vi.fn().mockResolvedValue({ items, total, pages })
|
||||
}
|
||||
|
||||
describe('useTableLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// --- 基础加载 ---
|
||||
|
||||
describe('基础加载', () => {
|
||||
it('load 执行 fetchFn 并更新 items', async () => {
|
||||
const mockItems = [{ id: 1, name: 'item1' }, { id: 2, name: 'item2' }]
|
||||
const fetchFn = createMockFetchFn(mockItems, 2, 1)
|
||||
|
||||
const { items, loading, load, pagination } = useTableLoader({
|
||||
fetchFn,
|
||||
})
|
||||
|
||||
expect(items.value).toHaveLength(0)
|
||||
|
||||
await load()
|
||||
|
||||
expect(items.value).toEqual(mockItems)
|
||||
expect(pagination.total).toBe(2)
|
||||
expect(pagination.pages).toBe(1)
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('load 期间 loading 为 true', async () => {
|
||||
let resolveLoad: (v: any) => void
|
||||
const fetchFn = vi.fn(
|
||||
() => new Promise((resolve) => { resolveLoad = resolve })
|
||||
)
|
||||
|
||||
const { loading, load } = useTableLoader({ fetchFn })
|
||||
|
||||
const p = load()
|
||||
expect(loading.value).toBe(true)
|
||||
|
||||
resolveLoad!({ items: [], total: 0, pages: 0 })
|
||||
await p
|
||||
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('使用默认 pageSize=20', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { load, pagination } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
1,
|
||||
20,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(pagination.page_size).toBe(20)
|
||||
})
|
||||
|
||||
it('可自定义 pageSize', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { load } = useTableLoader({ fetchFn, pageSize: 50 })
|
||||
|
||||
await load()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
1,
|
||||
50,
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 分页 ---
|
||||
|
||||
describe('分页', () => {
|
||||
it('handlePageChange 更新页码并加载', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load() // 初始加载
|
||||
fetchFn.mockClear()
|
||||
|
||||
handlePageChange(3)
|
||||
|
||||
expect(pagination.page).toBe(3)
|
||||
// 等待 load 完成
|
||||
await vi.runAllTimersAsync()
|
||||
expect(fetchFn).toHaveBeenCalledWith(3, 20, expect.anything(), expect.anything())
|
||||
})
|
||||
|
||||
it('handlePageSizeChange 重置到第1页并加载', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageSizeChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
pagination.page = 3
|
||||
fetchFn.mockClear()
|
||||
|
||||
handlePageSizeChange(50)
|
||||
|
||||
expect(pagination.page).toBe(1)
|
||||
expect(pagination.page_size).toBe(50)
|
||||
})
|
||||
|
||||
it('handlePageChange 限制页码范围', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { handlePageChange, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
|
||||
// 超出范围的页码被限制
|
||||
handlePageChange(999)
|
||||
expect(pagination.page).toBe(5) // 限制在 pages=5
|
||||
|
||||
handlePageChange(0)
|
||||
expect(pagination.page).toBe(1) // 最小为 1
|
||||
})
|
||||
})
|
||||
|
||||
// --- 搜索防抖 ---
|
||||
|
||||
describe('搜索防抖', () => {
|
||||
it('debouncedReload 在 300ms 内多次调用只执行一次', async () => {
|
||||
const fetchFn = createMockFetchFn()
|
||||
const { debouncedReload } = useTableLoader({ fetchFn })
|
||||
|
||||
// 快速连续调用
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
debouncedReload()
|
||||
|
||||
// 还没到 300ms,不应调用 fetchFn
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
|
||||
// 推进 300ms
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
// 等待异步完成
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reload 重置到第 1 页', async () => {
|
||||
const fetchFn = createMockFetchFn([], 100, 5)
|
||||
const { reload, pagination, load } = useTableLoader({ fetchFn })
|
||||
|
||||
await load()
|
||||
pagination.page = 3
|
||||
|
||||
await reload()
|
||||
|
||||
expect(pagination.page).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 请求取消 ---
|
||||
|
||||
describe('请求取消', () => {
|
||||
it('新请求取消前一个未完成的请求', async () => {
|
||||
let callCount = 0
|
||||
const fetchFn = vi.fn((_page, _size, _params, options) => {
|
||||
callCount++
|
||||
const currentCall = callCount
|
||||
return new Promise((resolve, reject) => {
|
||||
// 模拟监听 abort
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener('abort', () => {
|
||||
reject({ name: 'CanceledError', code: 'ERR_CANCELED' })
|
||||
})
|
||||
}
|
||||
// 异步解决
|
||||
setTimeout(() => {
|
||||
resolve({ items: [{ id: currentCall }], total: 1, pages: 1 })
|
||||
}, 1000)
|
||||
})
|
||||
})
|
||||
|
||||
const { load, items } = useTableLoader({ fetchFn })
|
||||
|
||||
// 第一次加载
|
||||
const p1 = load()
|
||||
// 第二次加载(应取消第一次)
|
||||
const p2 = load()
|
||||
|
||||
// 推进时间让第二次完成
|
||||
vi.advanceTimersByTime(1000)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
// 等待两个 Promise settle
|
||||
await Promise.allSettled([p1, p2])
|
||||
|
||||
// 第二次请求的结果生效
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 错误处理 ---
|
||||
|
||||
describe('错误处理', () => {
|
||||
it('非取消错误会被抛出', async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error('Server error'))
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
await expect(load()).rejects.toThrow('Server error')
|
||||
})
|
||||
|
||||
it('取消错误被静默处理', async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue({ name: 'CanceledError', code: 'ERR_CANCELED' })
|
||||
const { load } = useTableLoader({ fetchFn })
|
||||
|
||||
// 不应抛出
|
||||
await load()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user