288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
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: '<div data-test="role-select" :data-multiple="multiple"><slot /></div>'
|
||
}
|
||
|
||
const RoleOptionStub = {
|
||
props: ['label', 'value', 'disabled'],
|
||
template: '<span class="role-option">{{ label }}</span>'
|
||
}
|
||
|
||
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: `
|
||
<el-select v-model="ids" multiple>
|
||
<el-option v-for="role in roles" :key="role.id" :label="role.name" :value="role.id"
|
||
:disabled="${disabledExpression}" />
|
||
</el-select>
|
||
`,
|
||
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)
|
||
})
|
||
})
|