fix test errors

This commit is contained in:
2026-04-15 11:05:50 +08:00
parent 4809ad03e4
commit 51f0315d68
9 changed files with 402 additions and 45 deletions
+19 -14
View File
@@ -20,7 +20,7 @@ const createModalOpen = ref(false)
const createLoading = ref(false)
const createForm = reactive({
name: '',
expires_at: null as string | null,
expires_at: undefined as string | undefined,
})
// Plain key modal
@@ -50,7 +50,7 @@ const readonlyColumns = columns.filter((c) => c.key !== 'enabled' && c.key !== '
function showCreateModal() {
createForm.name = ''
createForm.expires_at = null
createForm.expires_at = undefined
createModalOpen.value = true
}
@@ -84,9 +84,10 @@ async function handleDelete(record: ApiKeyRecord) {
}
function handleCopyKey() {
navigator.clipboard.writeText(plainKey.value).then(() => {
message.success('已复制')
})
navigator.clipboard.writeText(plainKey.value).then(
() => message.success('已复制'),
() => message.error('复制失败,请手动复制'),
)
}
function handlePlainKeyModalClose() {
@@ -97,6 +98,10 @@ function handlePlainKeyModalClose() {
onMounted(() => {
apiKeyStore.fetchMyKeys()
})
onBeforeUnmount(() => {
plainKey.value = ''
})
</script>
<template>
@@ -120,17 +125,17 @@ onMounted(() => {
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
:row-class-name="(record: ApiKeyRecord) => isExpired(record as ApiKeyRecord) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
<span :class="{ 'text-red-500': isExpired(record as ApiKeyRecord) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
<a-tag v-if="isExpired(record as ApiKeyRecord)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'key_prefix'">
@@ -185,7 +190,7 @@ onMounted(() => {
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
:row-class-name="(record: ApiKeyRecord) => isExpired(record as ApiKeyRecord) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
@@ -198,10 +203,10 @@ onMounted(() => {
<template v-else-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
<span :class="{ 'text-red-500': isExpired(record as ApiKeyRecord) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
<a-tag v-if="isExpired(record as ApiKeyRecord)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'last_used_at'">
@@ -210,15 +215,15 @@ onMounted(() => {
<template v-else-if="column.key === 'enabled'">
<a-switch
:checked="record.enabled"
:disabled="isExpired(record)"
:disabled="isExpired(record as ApiKeyRecord)"
size="small"
@change="(checked: boolean) => handleToggle(record, checked)"
@change="(checked: boolean | string | number) => handleToggle(record as ApiKeyRecord, !!checked)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm
title="确定要删除此 API Key "
@confirm="handleDelete(record)"
@confirm="handleDelete(record as ApiKeyRecord)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
+12 -7
View File
@@ -186,13 +186,18 @@ function handleModeChange(val: unknown) {
}
// 树事件
function onCheck(checked: (string | number)[], info: { halfCheckedKeys: (string | number)[] }) {
checkedKeys.value = checked.map(String)
halfCheckedKeys.value = info.halfCheckedKeys.map(String)
function onCheck(
checked: (string | number)[] | { checked: (string | number)[]; halfChecked: (string | number)[] },
info: { halfCheckedKeys?: (string | number)[] },
) {
const keys = Array.isArray(checked) ? checked : checked.checked
checkedKeys.value = keys.map(String)
const half = Array.isArray(checked) ? (info.halfCheckedKeys ?? []) : checked.halfChecked
halfCheckedKeys.value = half.map(String)
}
function onExpand(keys: string[]) {
expandedKeys.value = keys
function onExpand(keys: (string | number)[]) {
expandedKeys.value = keys.map(String)
autoExpandParent.value = false
}
@@ -233,8 +238,8 @@ function scopesToCheckedKeys(userScopes: ScopeRecord[]): string[] {
}
// checkedKeys → scopes(智能判断全选/部分选)
function checkedKeysToScopes(): { scope_type: string; scope_id: number }[] {
const scopes: { scope_type: string; scope_id: number }[] = []
function checkedKeysToScopes(): Omit<ScopeRecord, 'name'>[] {
const scopes: Omit<ScopeRecord, 'name'>[] = []
const keySet = new Set(checkedKeys.value)
for (const key of checkedKeys.value) {
@@ -0,0 +1,323 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import { message } from 'ant-design-vue'
import ApiKeyPanel from '@/components/ApiKeyPanel.vue'
import { useUserStore } from '@/stores/user'
import { fakeJwtWithRole } from '@/test/helpers'
import type { ApiKeyRecord } from '@/types/api'
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
vi.mock('@/utils/request', () => ({
api: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}))
import { api } from '@/utils/request'
const mockWriteText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
writable: true,
configurable: true,
})
const mockKeys: ApiKeyRecord[] = [
{
id: 1,
user_id: 1,
name: '测试Key',
key_prefix: 'a1b2c3d4',
last_used_at: null,
expires_at: null,
enabled: true,
created_at: '2026-04-01T10:00:00',
},
{
id: 2,
user_id: 1,
name: '过期Key',
key_prefix: 'e5f6g7h8',
last_used_at: '2026-04-10T08:00:00',
expires_at: '2025-01-01T00:00:00',
enabled: true,
created_at: '2025-12-01T10:00:00',
},
]
function getSetupState(w: ReturnType<typeof mount>) {
// @ts-expect-error setupState is Vue internal, not in public types
return w.getCurrentComponent().setupState
}
describe('ApiKeyPanel', () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.restoreAllMocks()
mockWriteText.mockReset()
mockWriteText.mockResolvedValue(undefined)
document.body.innerHTML = ''
setActivePinia(createPinia())
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
function setupUser(apiKeyEnabled: boolean) {
const store = useUserStore()
store.setToken(fakeJwtWithRole('admin'), 'r.p.s', true)
store.setUser({
id: 1,
username: 'testuser',
email: 'test@example.com',
role: 'admin',
status: 1,
api_key_enabled: apiKeyEnabled,
})
}
function setupApi(keys: ApiKeyRecord[] = []) {
vi.mocked(api.get).mockImplementation((url: string) => {
if (url === '/api/v1/me/api-keys') return Promise.resolve(keys) as never
return Promise.resolve([]) as never
})
vi.mocked(api.post).mockResolvedValue({
plain_key: 'dk_test_plain_key_123',
api_key: { ...mockKeys[0], id: 99, name: '新Key' },
} as never)
vi.mocked(api.patch).mockImplementation(() => Promise.resolve(undefined) as never)
vi.mocked(api.delete).mockImplementation(() => Promise.resolve(undefined) as never)
}
async function mountPanel() {
wrapper = mount(ApiKeyPanel, { attachTo: document.body })
await flushPromises()
await nextTick()
return wrapper
}
function queryBody(selector: string) {
return document.body.querySelectorAll(selector)
}
describe('渲染模式', () => {
it('enabled + 有Key → 显示完整面板(表格+数量tag+生成按钮)', async () => {
setupUser(true)
setupApi(mockKeys)
await mountPanel()
const html = document.body.innerHTML
expect(html).toContain('2/10')
expect(queryBody('.ant-table').length).toBeGreaterThan(0)
const btns = Array.from(queryBody('.ant-btn-primary'))
expect(btns.some((btn) => btn.textContent?.includes('生成 API Key'))).toBe(true)
})
it('enabled + 无Key → 显示引导空状态', async () => {
setupUser(true)
setupApi([])
await mountPanel()
expect(queryBody('.ant-empty').length).toBeGreaterThan(0)
const btns = Array.from(queryBody('.ant-btn-primary'))
expect(btns.some((b) => b.textContent?.includes('生成第一个 API Key'))).toBe(true)
})
it('disabled + 无Key → 显示未启用提示', async () => {
setupUser(false)
setupApi([])
await mountPanel()
expect(document.body.innerHTML).toContain('API Key 功能未启用,请联系管理员开启')
})
it('disabled + 有Key → 显示警告Alert + 只读表格', async () => {
setupUser(false)
setupApi(mockKeys)
await mountPanel()
expect(queryBody('.ant-alert-warning').length).toBeGreaterThan(0)
expect(queryBody('.ant-table').length).toBeGreaterThan(0)
const btns = Array.from(queryBody('.ant-btn-primary'))
expect(btns.some((btn) => btn.textContent?.includes('生成 API Key'))).toBe(false)
expect(queryBody('.ant-switch').length).toBe(0)
})
})
describe('交互', () => {
it('点击生成按钮 → 打开创建 Modal', async () => {
setupUser(true)
setupApi(mockKeys)
await mountPanel()
const btn = Array.from(queryBody('.ant-btn-primary')).find(
(b) => b.textContent?.includes('生成 API Key'),
) as HTMLElement
btn.click()
await flushPromises()
await nextTick()
const footer = document.body.querySelector('.ant-modal-footer')
expect(footer).toBeTruthy()
})
it('提交创建 → 调用 createKey API → 显示明文 Modal', async () => {
setupUser(true)
setupApi(mockKeys)
await mountPanel()
const vm = getSetupState(wrapper)
vm.createForm.name = '新Key'
vm.createModalOpen = true
await nextTick()
await flushPromises()
const okBtn = document.body.querySelector(
'.ant-modal-footer .ant-btn-primary',
) as HTMLElement
okBtn?.click()
await flushPromises()
await nextTick()
await flushPromises()
expect(vi.mocked(api.post)).toHaveBeenCalledWith('/api/v1/me/api-keys', {
name: '新Key',
expires_at: undefined,
})
const html = document.body.innerHTML
expect(html).toContain('dk_test_plain_key_123')
expect(html).toContain('此密钥仅显示一次')
})
it('复制按钮 → 调用 clipboard.writeText', async () => {
setupUser(true)
setupApi(mockKeys)
await mountPanel()
const vm = getSetupState(wrapper)
vm.plainKey = 'dk_secret_key_abc'
vm.plainKeyModalOpen = true
await nextTick()
await flushPromises()
const copyBtn = Array.from(queryBody('.ant-btn-primary')).find(
(b) => b.textContent?.includes('复制'),
) as HTMLElement
copyBtn?.click()
await flushPromises()
expect(mockWriteText).toHaveBeenCalledWith('dk_secret_key_abc')
})
it('关闭明文 Modal → plainKey 清空', async () => {
setupUser(true)
setupApi(mockKeys)
await mountPanel()
const vm = getSetupState(wrapper)
vm.plainKey = 'dk_secret_key'
vm.plainKeyModalOpen = true
await nextTick()
vm.handlePlainKeyModalClose()
await nextTick()
expect(vm.plainKey).toBe('')
expect(vm.plainKeyModalOpen).toBe(false)
})
it('Toggle switch → 调用 toggleKey API', async () => {
setupUser(true)
setupApi(mockKeys)
await mountPanel()
expect(queryBody('.ant-switch').length).toBe(2)
await getSetupState(wrapper).handleToggle(mockKeys[0], false)
await flushPromises()
expect(vi.mocked(api.patch)).toHaveBeenCalledWith(
'/api/v1/me/api-keys/1/toggle',
{ enabled: false },
)
})
it('删除 → 调用 deleteKey API', async () => {
setupUser(true)
setupApi(mockKeys)
const successSpy = vi.spyOn(message, 'success')
await mountPanel()
await getSetupState(wrapper).handleDelete(mockKeys[0])
await flushPromises()
expect(vi.mocked(api.delete)).toHaveBeenCalledWith('/api/v1/me/api-keys/1')
expect(successSpy).toHaveBeenCalledWith('已删除')
})
})
describe('边界测试', () => {
it('达到10个上限 → 生成按钮禁用', async () => {
setupUser(true)
const tenKeys: ApiKeyRecord[] = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
user_id: 1,
name: `Key ${i + 1}`,
key_prefix: `prefix${String(i).padStart(2, '0')}`,
last_used_at: null,
expires_at: null,
enabled: true,
created_at: '2026-04-01T10:00:00',
}))
setupApi(tenKeys)
await mountPanel()
expect(document.body.innerHTML).toContain('10/10')
const disabledBtn = document.body.querySelector('.ant-btn-primary[disabled]')
expect(disabledBtn).toBeTruthy()
})
it('过期Key → 行变灰 + 已过期标签 + Switch 禁用', async () => {
setupUser(true)
setupApi(mockKeys)
await mountPanel()
expect(queryBody('.expired-row').length).toBeGreaterThanOrEqual(1)
expect(document.body.innerHTML).toContain('已过期')
const switches = Array.from(queryBody('.ant-switch'))
const disabledSwitches = switches.filter((s) =>
s.classList.contains('ant-switch-disabled'),
)
expect(disabledSwitches.length).toBeGreaterThanOrEqual(1)
})
it('onMounted → 调用 fetchMyKeys', async () => {
setupUser(true)
setupApi([])
await mountPanel()
expect(vi.mocked(api.get)).toHaveBeenCalledWith('/api/v1/me/api-keys')
})
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
@@ -29,17 +29,28 @@ function createTestRouter() {
}
describe('MainLayout', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wrapper: any
beforeEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
setActivePinia(createPinia())
localStorage.clear()
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
})
async function mountLayout(route = '/') {
const router = createTestRouter()
await router.push(route)
await router.isReady()
return mount(MainLayout, {
const w = mount(MainLayout, {
global: {
plugins: [router],
stubs: {
@@ -65,6 +76,8 @@ describe('MainLayout', () => {
default: '<div class="test-content">Page Content</div>',
},
})
wrapper = w
return w
}
it('renders all four layout areas', async () => {