import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessage } from 'element-plus'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import Users from './Users.vue'
import { organizationsApi } from '../api/organizations'
const RoleSelectStub = {
props: {
modelValue: Array,
multiple: Boolean
},
emits: ['update:modelValue'],
template: '
'
}
const RoleOptionStub = {
props: ['label', 'value', 'disabled'],
template: '{{ label }}'
}
const usersStore = vi.hoisted(() => ({
users: [],
loading: false,
page: { currentPage: 1, pageSize: 20, total: 0, totalPages: 1 },
fetchUsers: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn()
}))
vi.mock('../stores/users', () => ({ useUsersStore: () => usersStore }))
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
vi.mock('../api/organizations', () => ({
organizationsApi: {
listRoles: vi.fn()
}
}))
function mountUsers() {
return mount(Users, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub }
}
})
}
describe('用户管理角色', () => {
beforeEach(() => {
usersStore.users = [{
id: 7,
username: 'zhangsan',
description: '测试用户',
created_at: '2026-09-16T09:00:00+08:00',
roles: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
{ id: 3, code: 'purchaser', name: '采购人员', enabled: true, permissions: [] }
]
}]
usersStore.fetchUsers.mockReset()
organizationsApi.listRoles.mockResolvedValue({
data: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
{ id: 3, code: 'purchaser', name: '采购人员', enabled: true, permissions: [] }
]
})
})
test('列表显示用户的所有中文角色名称', async () => {
const wrapper = mount(Users, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub }
}
})
await flushPromises()
expect(wrapper.text()).toContain('仓库管理员')
expect(wrapper.text()).toContain('采购人员')
expect(wrapper.text()).not.toContain('普通用户')
wrapper.unmount()
})
test('新增和编辑使用角色与权限中的多选角色', async () => {
const wrapper = mount(Users, {
global: {
plugins: [ElementPlus],
stubs: { teleport: true, ElSelect: RoleSelectStub, ElOption: RoleOptionStub }
}
})
await flushPromises()
const addButton = wrapper.findAll('button').find(button => button.text().includes('新增用户'))
await addButton.trigger('click')
await flushPromises()
const roleSelect = wrapper.findComponent(RoleSelectStub)
expect(roleSelect.props('multiple')).toBe(true)
expect(wrapper.text()).toContain('仓库管理员')
expect(wrapper.text()).toContain('采购人员')
wrapper.unmount()
})
// 列表里同样要标出来:停用角色的 tag 和启用的一模一样,管理员会以为这个用户还在
// 通过它拿权限,实际上 GenerateToken 已经把它过滤掉了。
test('列表里已停用的角色也带后缀,启用的不带', async () => {
usersStore.users = [{
...usersStore.users[0],
roles: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
{ id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] }
]
}]
const wrapper = mountUsers()
await flushPromises()
expect(wrapper.text()).toContain('采购人员(已停用)')
expect(wrapper.text()).toContain('仓库管理员')
expect(wrapper.text()).not.toContain('仓库管理员(已停用)')
wrapper.unmount()
})
})
describe('新增用户后列表与搜索框保持一致', () => {
beforeEach(() => {
usersStore.fetchUsers.mockReset()
usersStore.createUser.mockReset()
usersStore.updateUser.mockReset()
usersStore.createUser.mockResolvedValue({ data: {} })
usersStore.updateUser.mockResolvedValue({ data: {} })
organizationsApi.listRoles.mockResolvedValue({ data: [] })
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
})
// store 里新建后的那次刷新是刻意清空筛选的(好让刚建的用户出现在列表里),页面这份
// 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着用户名、列表却是全量的
// 表里不一,用户会把全量列表当成筛选结果。
test('新增用户成功后搜索框要清空', async () => {
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
await wrapper.find('.search-bar input').setValue('zhangsan')
await flushPromises()
expect(state.searchKeyword).toBe('zhangsan')
state.form.username = 'lisi'
state.form.password = '123456'
state.form.role_ids = [2]
await state.handleSave()
await flushPromises()
expect(usersStore.createUser).toHaveBeenCalled()
expect(state.searchKeyword).toBe('')
wrapper.unmount()
})
// 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。
test('编辑用户成功后搜索框要保留', async () => {
const wrapper = mountUsers()
await flushPromises()
const state = wrapper.vm.$.setupState
await wrapper.find('.search-bar input').setValue('zhangsan')
await flushPromises()
state.editingId = 7
state.form.username = 'zhangsan'
state.form.role_ids = [2]
await state.handleSave()
await flushPromises()
expect(usersStore.updateUser).toHaveBeenCalled()
expect(state.searchKeyword).toBe('zhangsan')
wrapper.unmount()
})
})
// 已停用角色在下拉里怎么呈现,直接决定"编辑用户"这条路走不走得通:保存是全量替换
// 角色,前端若摘不掉那个停用角色、后端又要求角色全部启用,这个用户就永远存不回去。
// 现在后端已放行"本来就持有的停用角色",前端这两条是配套:既让人摘得掉,也不让人
// 新分配一个停用角色。
describe('已停用角色的选择状态', () => {
const disabledRole = { id: 3, code: 'purchaser', name: '采购人员', enabled: false, permissions: [] }
const unassignedDisabledRole = { id: 4, code: 'obsolete_role', name: '作废角色', enabled: false, permissions: [] }
beforeEach(() => {
// 用户 7 正持有已停用的角色 3;角色 4 同样停用但该用户没有
usersStore.users = [{
id: 7,
username: 'zhangsan',
description: '测试用户',
created_at: '2026-09-16T09:00:00+08:00',
roles: [disabledRole]
}]
usersStore.updateUser.mockReset()
usersStore.updateUser.mockResolvedValue({ data: {} })
organizationsApi.listRoles.mockResolvedValue({
data: [
{ id: 2, code: 'warehouse_admin', name: '仓库管理员', enabled: true, permissions: [] },
disabledRole,
unassignedDisabledRole
]
})
})
async function openEdit(wrapper) {
const state = wrapper.vm.$.setupState
state.showEditModal(usersStore.users[0])
await flushPromises()
return state
}
function findOption(wrapper, value) {
return wrapper.findAllComponents(RoleOptionStub).find(option => option.props('value') === value)
}
test('用户已持有的停用角色不禁用,否则它摘不掉', async () => {
const wrapper = mountUsers()
await flushPromises()
const state = await openEdit(wrapper)
expect(state.form.role_ids).toEqual([3])
// disabled 跟着 form.role_ids 走:已选中就不禁用,tag 上才有关闭按钮
expect(findOption(wrapper, 3).props('disabled')).toBe(false)
wrapper.unmount()
})
test('未持有的停用角色仍然禁用,不能通过编辑新分配', async () => {
const wrapper = mountUsers()
await flushPromises()
await openEdit(wrapper)
expect(findOption(wrapper, 4).props('disabled')).toBe(true)
wrapper.unmount()
})
test('停用的角色在下拉里标注出来', async () => {
const wrapper = mountUsers()
await flushPromises()
await openEdit(wrapper)
expect(findOption(wrapper, 3).props('label')).toBe('采购人员(已停用)')
expect(findOption(wrapper, 2).props('label')).toBe('仓库管理员')
wrapper.unmount()
})
})
// 上面几条用的是 stub,只能证明 :disabled 这个 prop 算对了;而"管理员究竟能不能把那个
// 停用角色摘掉"取决于 element-plus 内部——multiple 模式下 tag 的关闭按钮绑在 option 的
// isDisabled 上(select2.mjs 的 closable: !selectDisabled && !item.isDisabled)。这里是
// 用真实组件把这条依赖验一遍,否则 Users.vue 的修复只是纸面上成立。
describe('element-plus 多选下停用角色 tag 的可关闭性', () => {
const roles = [
{ id: 2, name: 'warehouse_admin', enabled: true },
{ id: 3, name: 'purchaser', enabled: false }
]
// 只铺一个最小场景:已选中那个停用的角色 3
function buildSelect(disabledExpression) {
return defineComponent({
template: `
`,
setup() {
return { ids: ref([3]), roles }
}
})
}
async function closeIconCount(disabledExpression) {
const wrapper = mount(buildSelect(disabledExpression), { global: { plugins: [ElementPlus] } })
await flushPromises()
return wrapper.findAll('.el-tag__close').length
}
test('Users.vue 的写法:已选中的停用角色 tag 保留关闭按钮', async () => {
expect(await closeIconCount('!role.enabled && !ids.includes(role.id)')).toBe(1)
})
test('静态 disabled 的写法:同一个 tag 没有关闭按钮(原死锁现场)', async () => {
expect(await closeIconCount('!role.enabled')).toBe(0)
})
})