Files
datahub/frontend/src/pages/users/__tests__/user-form.spec.ts
T

245 lines
8.2 KiB
TypeScript
Raw Normal View History

2026-03-18 15:25:17 +08:00
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
2026-03-19 08:44:32 +08:00
import { message } from 'ant-design-vue'
2026-03-18 15:25:17 +08:00
import UserFormModal from '@/components/UserFormModal.vue'
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: {
2026-03-19 08:44:32 +08:00
get: vi.fn(),
2026-03-18 15:25:17 +08:00
post: vi.fn(),
put: vi.fn(),
},
}))
const mockUserData = {
id: 1,
username: 'testuser',
email: 'test@test.com',
status: 1,
role_id: 1,
ext: null,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
describe('UserFormModal', () => {
let wrapper: ReturnType<typeof mount>
beforeEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
afterEach(() => {
wrapper?.unmount()
document.body.innerHTML = ''
})
async function mountModal(props = {}) {
wrapper = mount(UserFormModal, {
props: {
open: true,
mode: 'create' as const,
userData: null,
...props,
},
attachTo: document.body,
})
await flushPromises()
await nextTick()
return wrapper
}
// 查找 teleported 到 body 的模态框内容
function queryBody(selector: string) {
return document.body.querySelectorAll(selector)
}
describe('create mode', () => {
it('renders create title', async () => {
await mountModal()
const title = document.body.querySelector('.ant-modal-title')
expect(title?.textContent).toBe('新建用户')
})
it('shows password field in create mode', async () => {
await mountModal()
const labels = Array.from(queryBody('.ant-form-item label'))
.map((el) => el.textContent?.trim())
expect(labels).toContain('密码')
})
2026-03-19 08:44:32 +08:00
it('shows all five form fields', async () => {
2026-03-18 15:25:17 +08:00
await mountModal()
const formItems = queryBody('.ant-form-item')
2026-03-19 08:44:32 +08:00
expect(formItems).toHaveLength(5) // username, password, email, status, role
2026-03-18 15:25:17 +08:00
})
it('emits update:open false on cancel', async () => {
const w = await mountModal()
// 找到取消按钮(模态框 footer 中非 primary 按钮)
const buttons = Array.from(queryBody('.ant-modal-footer .ant-btn'))
const cancelBtn = buttons.find((b) => !b.classList.contains('ant-btn-primary'))
cancelBtn?.dispatchEvent(new Event('click'))
await flushPromises()
expect(w.emitted('update:open')?.[0]).toEqual([false])
})
})
describe('edit mode', () => {
it('renders edit title', async () => {
await mountModal({ mode: 'edit', userData: mockUserData })
const title = document.body.querySelector('.ant-modal-title')
expect(title?.textContent).toBe('编辑用户')
})
it('hides password field in edit mode', async () => {
await mountModal({ mode: 'edit', userData: mockUserData })
const labels = Array.from(queryBody('.ant-form-item label'))
.map((el) => el.textContent?.trim())
expect(labels).not.toContain('密码')
})
2026-03-19 08:44:32 +08:00
it('shows four form fields (no password)', async () => {
2026-03-18 15:25:17 +08:00
await mountModal({ mode: 'edit', userData: mockUserData })
const formItems = queryBody('.ant-form-item')
2026-03-19 08:44:32 +08:00
expect(formItems).toHaveLength(4) // username, email, status, role
2026-03-18 15:25:17 +08:00
})
it('prefills form with user data', async () => {
// 先以 open=false 挂载,再切换为 true 触发 watch 预填数据(与实际使用流程一致)
const w = await mountModal({ mode: 'edit', userData: mockUserData, open: false })
await w.setProps({ open: true })
await flushPromises()
await nextTick()
const inputs = Array.from(queryBody('input')) as HTMLInputElement[]
const values = inputs.map((i) => i.value)
expect(values).toContain('testuser')
expect(values).toContain('test@test.com')
})
})
2026-03-19 08:44:32 +08:00
describe('edge cases', () => {
it('shows warning when role list fetch fails', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.get).mockRejectedValueOnce(new Error('Network error'))
const warnSpy = vi.spyOn(message, 'warning').mockImplementation(() => ({}) as never)
// open=false then switch to true triggers fetchRoles
const w = await mountModal({ open: false })
await w.setProps({ open: true })
await flushPromises()
await nextTick()
expect(warnSpy).toHaveBeenCalledWith('获取角色列表失败,角色选择不可用')
warnSpy.mockRestore()
})
it('shows warning when user created but role assignment fails', async () => {
const { api } = await import('@/utils/request')
// fetchRoles succeeds
vi.mocked(api.get).mockResolvedValueOnce([{ id: 1, name: 'administrator' }])
// user create succeeds
vi.mocked(api.post).mockResolvedValueOnce({ id: 99 })
// role assignment fails
vi.mocked(api.put).mockRejectedValueOnce(new Error('Role assign failed'))
const warnSpy = vi.spyOn(message, 'warning').mockImplementation(() => ({}) as never)
const successSpy = vi.spyOn(message, 'success').mockImplementation(() => ({}) as never)
const w = await mountModal({ open: false })
await w.setProps({ open: true })
await flushPromises()
await nextTick()
// Set role_id via component internals
const vm = w.vm as unknown as { formState: { username: string; password: string; email: string; role_id: number | undefined } }
vm.formState.username = 'newuser'
vm.formState.password = 'pass123456'
vm.formState.email = 'new@test.com'
vm.formState.role_id = 1
await nextTick()
// Submit
const okBtn = document.body.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement
okBtn?.click()
await flushPromises()
await nextTick()
await flushPromises()
// Role assignment warning shown but overall success emitted
expect(warnSpy).toHaveBeenCalledWith('用户已保存,但角色分配失败,请稍后在用户列表中重试')
expect(successSpy).toHaveBeenCalledWith('操作成功')
expect(w.emitted('success')).toBeTruthy()
warnSpy.mockRestore()
successSpy.mockRestore()
})
})
2026-03-18 15:25:17 +08:00
describe('form submission', () => {
it('calls api.post for create mode', async () => {
const { api } = await import('@/utils/request')
vi.mocked(api.post).mockResolvedValueOnce({})
await mountModal()
// 填充表单
const inputs = Array.from(queryBody('.ant-form-item input')) as HTMLInputElement[]
expect(inputs.length).toBeGreaterThanOrEqual(3)
inputs[0]!.value = 'newuser'
inputs[0]!.dispatchEvent(new Event('input'))
inputs[1]!.value = 'password123'
inputs[1]!.dispatchEvent(new Event('input'))
inputs[2]!.value = 'new@test.com'
inputs[2]!.dispatchEvent(new Event('input'))
await flushPromises()
// 点击确定按钮
const okBtn = document.body.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement
okBtn?.click()
await flushPromises()
})
it('calls api.put for edit mode with status field', async () => {
2026-03-18 15:25:17 +08:00
const { api } = await import('@/utils/request')
vi.mocked(api.put).mockResolvedValueOnce({})
// 先以 open=false 挂载,再切换为 true 触发 watch 预填数据,使表单验证通过
const w = await mountModal({ mode: 'edit', userData: mockUserData, open: false })
await w.setProps({ open: true })
await flushPromises()
await nextTick()
2026-03-18 15:25:17 +08:00
// 点击确定按钮
const okBtn = document.body.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement
expect(okBtn).toBeTruthy()
okBtn?.click()
await flushPromises()
// 验证 PUT payload 包含 status 且不包含 ext
expect(api.put).toHaveBeenCalledOnce()
2026-03-18 20:08:04 +08:00
const [url, payload] = vi.mocked(api.put).mock.calls[0]!
expect(url).toContain('/api/v1/users/')
expect(payload).toHaveProperty('status')
expect(payload).not.toHaveProperty('ext')
2026-03-18 15:25:17 +08:00
})
})
})