399 lines
16 KiB
JavaScript
399 lines
16 KiB
JavaScript
/**
|
||
* 分配权限抽屉(UserPowerDrawer):头部结构 + 账号级批量操作。
|
||
*
|
||
* 数据加载与保存由本组件负责(面板只读展示),测试只锁这几件事:
|
||
* 1. 头部常规显示:左侧账号标识标题 + 右侧关闭按钮,页签在内容区顶部;
|
||
* 2. 页签行右端的三个账号级批量按钮(全授权 / 全禁用 / 清空权限):挂在 Tabs 的
|
||
* rightExtra 插槽上,横跨三个页签、与选中哪个菜单 / 模块无关,改完即落库;
|
||
* 三者轻重不同,样式分三档(主色 / 描边 / 危险描边),不是三个同款;
|
||
* 「全授权 / 全禁用」管功能权限,「清空权限」连字段权限与数据范围一起清(先过确认框);
|
||
* 后两者的整表清除按 b_user_id 成删除条件提交,不逐行枚举主键(见最后一节);
|
||
* 3. 三个页签对应三张权限表的内容面板(切换页签不销毁,用 v-show 承载);
|
||
* 左右两栏都走 Splitter 分栏(栏宽可拖拽),模块树那一栏两个页签共用;
|
||
* 4. 即时落库:底部没有「保存 / 取消」按钮,任何改动都立刻按 diff 提交(见最后一节)。
|
||
*
|
||
* 面板自身的规则由 user-power-panel.spec.js 覆盖,这里不重复。
|
||
*/
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||
import { mount, flushPromises } from '@vue/test-utils'
|
||
import { nextTick } from 'vue'
|
||
import UserPowerDrawer from '@/views/system/user/UserPowerDrawer.vue'
|
||
import UserPowerPanel from '@/views/system/user/UserPowerPanel.vue'
|
||
import UserFieldPowerPanel from '@/views/system/user/UserFieldPowerPanel.vue'
|
||
import UserDataPowerPanel from '@/views/system/user/UserDataPowerPanel.vue'
|
||
import { clearMessages } from '@/stores/i18n'
|
||
|
||
vi.mock('@/services/api', () => ({
|
||
loadDataApi: vi.fn(),
|
||
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
|
||
}))
|
||
|
||
// 提示只按"有没有给"断言(提交失败等分支的表现就是提示一句),不测具体渲染
|
||
vi.mock('@/components/ui/message/message-manager.js', () => {
|
||
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
|
||
return { Message: m, default: m }
|
||
})
|
||
|
||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||
import { Message } from '@/components/ui/message/message-manager.js'
|
||
|
||
/** 按表返回的行:权限点与三张授权表各留一条,三个批量按钮的效果才有可验证的落差 */
|
||
const TABLE_ROWS = {
|
||
s_power: [
|
||
{ b_id: 'menu.partner', b_object_type: 'menu', b_object_id: 'partner', b_xh: 10, b_canuse: 1 },
|
||
{
|
||
b_id: 'module.v_b_othercompany',
|
||
b_object_type: 'module',
|
||
b_object_id: 'v_b_othercompany',
|
||
b_xh: 10,
|
||
b_canuse: 1,
|
||
},
|
||
{
|
||
b_id: 'action.v_b_othercompany.create',
|
||
b_object_type: 'module',
|
||
b_object_id: 'v_b_othercompany',
|
||
b_xh: 20,
|
||
b_canuse: 1,
|
||
},
|
||
],
|
||
s_user_power: [{ b_user_id: '001', b_power_id: 'menu.partner', b_canuse: 1 }],
|
||
s_user_field_power: [
|
||
{
|
||
b_user_id: '001',
|
||
b_module_id: 'v_b_othercompany',
|
||
b_field_id: 'b_name',
|
||
b_view: 0,
|
||
b_edit: 0,
|
||
b_query: 1,
|
||
b_export: 1,
|
||
b_canuse: 1,
|
||
},
|
||
],
|
||
s_user_data_power: [
|
||
{
|
||
b_user_id: '001',
|
||
b_module_id: 'v_b_othercompany',
|
||
b_operation: '*',
|
||
b_xh: 1,
|
||
b_scope_type: 'dept',
|
||
b_canuse: 1,
|
||
},
|
||
],
|
||
}
|
||
|
||
/** s_power 全量启用编码(含 menu.*):「全授权」要一次勾满这些 */
|
||
const ALL_POWER_IDS = TABLE_ROWS.s_power.map((row) => row.b_id)
|
||
|
||
/** 页签行右端的批量按钮:0 = 全授权,1 = 全禁用,2 = 清空权限 */
|
||
function actionButton(wrapper, index) {
|
||
return wrapper.findAll('.power-drawer__actions button')[index]
|
||
}
|
||
|
||
/** 命令式确认框(Modal.confirm 自建容器挂到 body,只能从 document 上找) */
|
||
function confirmPanel() {
|
||
return [...document.querySelectorAll('.fms-modal')].find((el) =>
|
||
el.textContent.includes('全部权限'),
|
||
)
|
||
}
|
||
|
||
/** 确认框底部按钮顺序:[取消, 确定] */
|
||
function confirmFooterButtons(panel) {
|
||
return [...panel.querySelectorAll('.fms-modal-footer button')]
|
||
}
|
||
|
||
beforeEach(() => {
|
||
localStorage.clear()
|
||
sessionStorage.clear()
|
||
clearMessages()
|
||
loadDataApi.mockReset()
|
||
saveObjectApi.mockClear()
|
||
// 只有本用例关心的表返回数据,其余表(菜单、模块、字段……)空数组即可
|
||
loadDataApi.mockImplementation((table) => Promise.resolve({ data: TABLE_ROWS[table] || [] }))
|
||
})
|
||
|
||
async function mountDrawer() {
|
||
const wrapper = mount(UserPowerDrawer, {
|
||
props: { open: true, user: { b_id: '001', b_name: '张三' } },
|
||
// Drawer 用 Teleport 到 body;打桩后内容留在原地,选择器才能命中
|
||
global: { stubs: { teleport: true } },
|
||
})
|
||
await flushPromises()
|
||
return wrapper
|
||
}
|
||
|
||
describe('分配权限抽屉:头部', () => {
|
||
it('头部常规显示:账号标识标题 + 关闭按钮,页签在内容区', async () => {
|
||
const wrapper = await mountDrawer()
|
||
|
||
expect(wrapper.find('.fms-drawer-title').text()).toBe('分配权限 · 张三(001)')
|
||
expect(wrapper.find('.fms-drawer-close').exists()).toBe(true)
|
||
// header 让给账号标识:页签不在 header 里,在内容区顶部
|
||
expect(wrapper.find('.fms-drawer-header .tabs-nav').exists()).toBe(false)
|
||
expect(wrapper.find('.power-drawer__nav .tabs-nav').exists()).toBe(true)
|
||
// 账号级批量按钮也不占头部:它们与页签同行,在页签行右端
|
||
expect(wrapper.find('.fms-drawer-header button.button').exists()).toBe(false)
|
||
})
|
||
|
||
it('账号缺名称时标题只回退到编码', async () => {
|
||
const wrapper = mount(UserPowerDrawer, {
|
||
props: { open: true, user: { b_id: '001' } },
|
||
global: { stubs: { teleport: true } },
|
||
})
|
||
await flushPromises()
|
||
|
||
expect(wrapper.find('.fms-drawer-title').text()).toBe('分配权限 · 001')
|
||
})
|
||
|
||
it('三个页签对应三张权限表的内容面板', async () => {
|
||
const wrapper = await mountDrawer()
|
||
|
||
expect(wrapper.findAll('.tabs-tab').map((el) => el.text())).toEqual([
|
||
'功能权限',
|
||
'字段权限',
|
||
'数据范围',
|
||
])
|
||
// 功能权限自带一棵菜单树,是独立面板;字段权限 / 数据范围共用一个面板容器
|
||
// (同一棵模块树 + 各自的内容区),所以 data-pane 落在内容区上
|
||
expect(wrapper.findAll('[data-pane]').map((el) => el.attributes('data-pane'))).toEqual([
|
||
'power',
|
||
'field',
|
||
'data',
|
||
])
|
||
})
|
||
|
||
it('字段权限 / 数据范围:左模块树与右内容卡同样可拖拽调宽', async () => {
|
||
const wrapper = await mountDrawer()
|
||
|
||
// 模块树那一栏由抽屉渲染(两个页签共用),是内容区的直接子节点(功能权限面板自带一棵菜单树)
|
||
const moduleSplitter = wrapper.find('.power-drawer__body > .fms-splitter')
|
||
expect(moduleSplitter.exists()).toBe(true)
|
||
expect(moduleSplitter.find('.fms-splitter-bar').exists()).toBe(true)
|
||
|
||
const panels = moduleSplitter.findAll('.fms-splitter-panel')
|
||
expect(panels).toHaveLength(2)
|
||
expect(panels[0].find('.fms-tree-panel').exists()).toBe(true)
|
||
// 两个面板叠在右栏里(v-show 只显一个):切页签只换内容,不重挂树
|
||
expect(panels[1].findAll('[data-pane]').map((el) => el.attributes('data-pane'))).toEqual([
|
||
'field',
|
||
'data',
|
||
])
|
||
})
|
||
})
|
||
|
||
describe('分配权限抽屉:账号级批量操作', () => {
|
||
it('三个按钮在页签行右端:全授权 / 全禁用 / 清空权限', async () => {
|
||
const wrapper = await mountDrawer()
|
||
|
||
const actions = wrapper.find('.power-drawer__nav .power-drawer__actions')
|
||
expect(actions.exists()).toBe(true)
|
||
// 走 Tabs 的 rightExtra 插槽:按钮落在标签栏右端(.tabs-nav-extra),与页签同一行
|
||
expect(
|
||
wrapper
|
||
.find('.power-drawer__nav .tabs-nav .tabs-nav-extra .power-drawer__actions')
|
||
.exists(),
|
||
).toBe(true)
|
||
expect(actions.findAll('button').map((el) => el.text())).toEqual([
|
||
'全授权',
|
||
'全禁用',
|
||
'清空权限',
|
||
])
|
||
// 三者轻重不同,样式分三档:给(主色实心)/ 反向(描边)/ 破坏性(危险色描边)
|
||
const buttonTypes = actions.findAll('button').map((el) => el.classes())
|
||
expect(buttonTypes[0]).toContain('button-primary')
|
||
expect(buttonTypes[1]).toContain('button-outline')
|
||
expect(buttonTypes[2]).toContain('button-danger-outline')
|
||
// 作用范围写在 title 里(按钮文字只够放下动作名)
|
||
expect(actions.findAll('button').map((el) => el.attributes('title'))).toEqual([
|
||
'勾满该账号的全部功能权限(菜单入口、模块与业务动作)',
|
||
'取消该账号的全部功能权限(字段权限与数据范围不动)',
|
||
'清空该账号的功能权限、字段权限与数据范围',
|
||
])
|
||
})
|
||
|
||
it('全授权:一次勾满 s_power 的全部权限点,与选中哪个菜单无关', async () => {
|
||
const wrapper = await mountDrawer()
|
||
|
||
await actionButton(wrapper, 0).trigger('click')
|
||
|
||
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual(ALL_POWER_IDS)
|
||
// 字段权限与数据范围不是「功能」权限,全授权不动它们
|
||
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toHaveLength(1)
|
||
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toHaveLength(1)
|
||
})
|
||
|
||
it('全禁用:只取消功能权限,字段权限与数据范围不动', async () => {
|
||
const wrapper = await mountDrawer()
|
||
|
||
await actionButton(wrapper, 1).trigger('click')
|
||
|
||
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual([])
|
||
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toHaveLength(1)
|
||
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toHaveLength(1)
|
||
})
|
||
|
||
it('清空权限:确认前不动数据,确认后三类权限一起清空', async () => {
|
||
const wrapper = await mountDrawer()
|
||
// 三类都有数据,清空才有可验证的落差
|
||
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual(['menu.partner'])
|
||
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toHaveLength(1)
|
||
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toHaveLength(1)
|
||
|
||
await actionButton(wrapper, 2).trigger('click')
|
||
|
||
// 三类一次清到底是重操作:先问一次,确认前什么都不动
|
||
const panel = confirmPanel()
|
||
expect(panel).toBeTruthy()
|
||
expect(panel.textContent).toContain('确定清空「张三(001)」的全部权限?')
|
||
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual(['menu.partner'])
|
||
|
||
confirmFooterButtons(panel).at(-1).click()
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual([])
|
||
expect(wrapper.findComponent(UserFieldPowerPanel).props('modelValue')).toEqual([])
|
||
expect(wrapper.findComponent(UserDataPowerPanel).props('modelValue')).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('分配权限抽屉:改动即时落库', () => {
|
||
it('底部没有「保存 / 取消」按钮:改动不经过二次确认', async () => {
|
||
const wrapper = await mountDrawer()
|
||
|
||
// Drawer 只在给了 footer 插槽时才渲染底部条:没有保存流程,也没有取消按钮
|
||
expect(wrapper.find('.fms-drawer-footer').exists()).toBe(false)
|
||
expect(wrapper.find('.power-footer').exists()).toBe(false)
|
||
// 关闭入口只剩头部的关闭按钮
|
||
expect(wrapper.find('.fms-drawer-close').exists()).toBe(true)
|
||
})
|
||
|
||
it('全授权:改完立刻提交,只提交基线上没有的权限点(插入,不删原有的)', async () => {
|
||
const wrapper = await mountDrawer()
|
||
saveObjectApi.mockClear()
|
||
|
||
await actionButton(wrapper, 0).trigger('click')
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||
const [requests] = saveObjectApi.mock.calls[0]
|
||
expect(requests.map((req) => req.table)).toEqual(['s_user_power'])
|
||
expect(requests[0].key_field).toBe('b_user_id,b_power_id')
|
||
// 顺序按 s_power 的加载顺序,与断言无关,排序后再比
|
||
expect(requests[0].inserts.map((row) => row.b_power_id).sort()).toEqual([
|
||
'action.v_b_othercompany.create',
|
||
'module.v_b_othercompany',
|
||
])
|
||
// 原有的菜单入口仍在勾选集合里,不产生删除
|
||
expect(requests[0].deletes).toBeUndefined()
|
||
})
|
||
|
||
it('全禁用:改完立刻提交,按账号整表撤销功能权限(不枚举主键行)', async () => {
|
||
const wrapper = await mountDrawer()
|
||
saveObjectApi.mockClear()
|
||
|
||
await actionButton(wrapper, 1).trigger('click')
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||
const [requests] = saveObjectApi.mock.calls[0]
|
||
expect(requests.map((req) => req.table)).toEqual(['s_user_power'])
|
||
// 删除条件就是行里的列(b_user_id + 生效口径),不是逐行的主键值
|
||
expect(requests[0].deletes).toEqual([{ b_user_id: '001', b_canuse: 1 }])
|
||
expect(requests[0].inserts).toBeUndefined()
|
||
})
|
||
|
||
it('清空权限:确认后三张表在同一个请求列表里提交', async () => {
|
||
const wrapper = await mountDrawer()
|
||
saveObjectApi.mockClear()
|
||
|
||
await actionButton(wrapper, 2).trigger('click')
|
||
confirmFooterButtons(confirmPanel()).at(-1).click()
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||
const [requests] = saveObjectApi.mock.calls[0]
|
||
expect(requests.map((req) => req.table)).toEqual([
|
||
's_user_power',
|
||
's_user_field_power',
|
||
's_user_data_power',
|
||
])
|
||
// 三类都按该账号整表清:条件相同(b_user_id + 生效口径),各自带上自己的主键列
|
||
expect(requests.map((req) => req.key_field)).toEqual([
|
||
'b_user_id,b_power_id',
|
||
'b_user_id,b_module_id,b_field_id',
|
||
'b_user_id,b_module_id,b_operation,b_xh',
|
||
])
|
||
expect(requests.map((req) => req.deletes)).toEqual([
|
||
[{ b_user_id: '001', b_canuse: 1 }],
|
||
[{ b_user_id: '001', b_canuse: 1 }],
|
||
[{ b_user_id: '001', b_canuse: 1 }],
|
||
])
|
||
expect(requests.map((req) => req.inserts)).toEqual([undefined, undefined, undefined])
|
||
})
|
||
|
||
it('提交在飞时又勾回来的权限:随整表清除那一批写回,不会在库里被删掉', async () => {
|
||
// 第一轮卡住不返回,制造"提交在飞"的窗口
|
||
let releaseFirst
|
||
saveObjectApi.mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
releaseFirst = resolve
|
||
}),
|
||
)
|
||
const wrapper = await mountDrawer()
|
||
saveObjectApi.mockClear()
|
||
|
||
// 第一轮:全授权(这一轮快照里含原有的菜单入口勾选)
|
||
await actionButton(wrapper, 0).trigger('click')
|
||
await flushPromises()
|
||
|
||
// 在飞期间:先全禁用(登记整表清除),紧接着又把菜单入口勾回来
|
||
await actionButton(wrapper, 1).trigger('click')
|
||
await wrapper.findComponent(UserPowerPanel).vm.$emit('update:modelValue', ['menu.partner'])
|
||
await flushPromises()
|
||
|
||
releaseFirst({ code: 0, success: true })
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect(saveObjectApi).toHaveBeenCalledTimes(2)
|
||
const [requests] = saveObjectApi.mock.calls[1]
|
||
expect(requests[0].deletes).toEqual([{ b_user_id: '001', b_canuse: 1 }])
|
||
// 这个权限点在基线里本来就有,diff 看不出变化,必须随本批写回
|
||
expect(requests[0].inserts).toEqual([
|
||
{ b_user_id: '001', b_power_id: 'menu.partner', b_canuse: 1 },
|
||
])
|
||
})
|
||
|
||
it('重复点同一个按钮不会重复提交:差异已落库,下一轮直接早退', async () => {
|
||
const wrapper = await mountDrawer()
|
||
saveObjectApi.mockClear()
|
||
|
||
await actionButton(wrapper, 1).trigger('click')
|
||
await flushPromises()
|
||
await nextTick()
|
||
await actionButton(wrapper, 1).trigger('click')
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('提交失败:保留界面上的改动并提示(下一次改动连着重发)', async () => {
|
||
saveObjectApi.mockRejectedValueOnce(new Error('网络异常'))
|
||
const wrapper = await mountDrawer()
|
||
saveObjectApi.mockClear()
|
||
|
||
await actionButton(wrapper, 1).trigger('click')
|
||
await flushPromises()
|
||
await nextTick()
|
||
|
||
// 失败不回退:面板上的勾选仍是"已清空",提示里说明这一批没有落库
|
||
expect(wrapper.findComponent(UserPowerPanel).props('modelValue')).toEqual([])
|
||
expect(Message.error).toHaveBeenCalledWith('权限保存失败(改动未落库):网络异常')
|
||
})
|
||
})
|