318 lines
10 KiB
JavaScript
318 lines
10 KiB
JavaScript
/**
|
||
* 回归用例:菜单管理 - 随菜单保存同步的两份子表数据。
|
||
*
|
||
* 1) 绑定模块(s_menu_module)
|
||
* s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id),
|
||
* 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。
|
||
* 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。
|
||
*
|
||
* 2) 菜单入口权限点(s_power, b_object_type = 'menu')
|
||
* 页面 / 外链菜单各派生一行 menu.{菜单编码}(b_action = 'access',名称/顺序/启用跟随菜单),
|
||
* 目录没有;保存时按菜单行与该菜单的加载基线 diff;删除菜单时连同授权行一并清理。
|
||
*/
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||
import { flushPromises } from '@vue/test-utils'
|
||
import MenuManagement from '@/views/module/menu-management/index.vue'
|
||
import { mountPage } from '../helpers/app.js'
|
||
|
||
vi.mock('@/services/api', () => ({
|
||
loadDataApi: vi.fn(),
|
||
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
|
||
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
|
||
describeApi: vi.fn(() => Promise.resolve({ data: [], 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'
|
||
|
||
const directoryMenu = {
|
||
b_id: 'base',
|
||
b_parent_id: null,
|
||
b_depth: 0,
|
||
b_path: '/base/',
|
||
b_name: '基础资料',
|
||
b_i18n: 'menu.base',
|
||
b_menu_type: 'directory',
|
||
b_route: null,
|
||
b_icon: null,
|
||
b_xh: 10,
|
||
b_canuse: 1,
|
||
}
|
||
|
||
const pageMenu = {
|
||
b_id: 'otherdata',
|
||
b_parent_id: 'base',
|
||
b_depth: 1,
|
||
b_path: '/base/otherdata/',
|
||
b_name: '其他数据',
|
||
b_i18n: 'menu.otherdata',
|
||
b_menu_type: 'page',
|
||
b_route: '/base/otherdata',
|
||
b_icon: null,
|
||
b_xh: 10,
|
||
b_canuse: 1,
|
||
}
|
||
|
||
/** 模块清单:base / base_otherdata 为分类节点,b_port / b_money 为数据模块 */
|
||
const modules = [
|
||
{
|
||
b_id: 'base',
|
||
b_parent_id: null,
|
||
b_depth: 0,
|
||
b_path: '/base/',
|
||
b_module_type: 'module',
|
||
b_name: '基础资料',
|
||
b_canuse: 1,
|
||
b_xh: 10,
|
||
},
|
||
{
|
||
b_id: 'base_otherdata',
|
||
b_parent_id: 'base',
|
||
b_depth: 1,
|
||
b_path: '/base/base_otherdata/',
|
||
b_module_type: 'module',
|
||
b_name: '其他数据',
|
||
b_canuse: 1,
|
||
b_xh: 30,
|
||
},
|
||
{
|
||
b_id: 'b_port',
|
||
b_parent_id: 'base_otherdata',
|
||
b_depth: 2,
|
||
b_path: '/base/base_otherdata/b_port/',
|
||
b_module_type: 'data',
|
||
b_name: '港口',
|
||
b_canuse: 1,
|
||
b_xh: 10,
|
||
},
|
||
{
|
||
b_id: 'b_money',
|
||
b_parent_id: 'base_otherdata',
|
||
b_depth: 2,
|
||
b_path: '/base/base_otherdata/b_money/',
|
||
b_module_type: 'data',
|
||
b_name: '币种',
|
||
b_canuse: 1,
|
||
b_xh: 20,
|
||
},
|
||
]
|
||
|
||
/** 库中 s_menu_module 的现有行(每个用例前重置) */
|
||
let boundRows = []
|
||
/** 库中该菜单的 s_power(menu.*) 行(每个用例前重置) */
|
||
let powerRows = []
|
||
|
||
/** 从查询条件里取 N'xxx' 字面量(mock 按 id 过滤用;'1 = 1' 等条件返回 null) */
|
||
function literalOf(condition) {
|
||
return /N'([^']*)'/.exec(String(condition || ''))?.[1] ?? null
|
||
}
|
||
|
||
beforeEach(() => {
|
||
boundRows = [{ b_menu_id: 'otherdata', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
|
||
powerRows = []
|
||
loadDataApi.mockReset()
|
||
saveObjectApi.mockClear()
|
||
loadDataApi.mockImplementation((view, condition) => {
|
||
if (view === 's_menu') {
|
||
const id = literalOf(condition)
|
||
const rows = [directoryMenu, pageMenu].filter((row) => !id || row.b_id === id)
|
||
return Promise.resolve({ data: rows })
|
||
}
|
||
if (view === 's_module') return Promise.resolve({ data: modules })
|
||
if (view === 's_menu_module') {
|
||
const id = literalOf(condition)
|
||
return Promise.resolve({
|
||
data: boundRows.filter((row) => !id || row.b_menu_id === id),
|
||
})
|
||
}
|
||
if (view === 's_power') {
|
||
const id = literalOf(condition)
|
||
return Promise.resolve({
|
||
data: powerRows.filter((row) => !id || row.b_object_id === id),
|
||
})
|
||
}
|
||
return Promise.resolve({ data: [] })
|
||
})
|
||
})
|
||
|
||
/** 取出最近一次保存请求里的指定表请求 */
|
||
function lastRequestOf(table) {
|
||
const requests = saveObjectApi.mock.calls.at(-1)[0]
|
||
return requests.find((request) => request.table === table)
|
||
}
|
||
|
||
describe('菜单管理:绑定模块(s_menu_module)', () => {
|
||
it('选中菜单时按 b_menu_id 加载绑定模块', async () => {
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
|
||
wrapper.vm.menuSelectedId = 'otherdata'
|
||
await flushPromises()
|
||
|
||
expect(loadDataApi).toHaveBeenCalledWith(
|
||
's_menu_module',
|
||
"b_menu_id = N'otherdata'",
|
||
'b_xh ASC, b_module_id ASC',
|
||
)
|
||
expect(wrapper.vm.menuModules).toEqual(['b_port'])
|
||
})
|
||
|
||
it('新增绑定:只提交 s_menu_module 的 insert 行', async () => {
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
wrapper.vm.menuSelectedId = 'otherdata'
|
||
await flushPromises()
|
||
|
||
wrapper.vm.menuModules = ['b_port', 'b_money']
|
||
await wrapper.vm.handleSave()
|
||
|
||
const request = lastRequestOf('s_menu_module')
|
||
expect(request.key_field).toBe('b_menu_id,b_module_id')
|
||
expect(request.inserts).toEqual([
|
||
{ b_menu_id: 'otherdata', b_module_id: 'b_money', b_xh: 20, b_canuse: 1 },
|
||
])
|
||
expect(request.deletes).toBeUndefined()
|
||
})
|
||
|
||
it('移除绑定:只提交 s_menu_module 的 delete 主键行', async () => {
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
wrapper.vm.menuSelectedId = 'otherdata'
|
||
await flushPromises()
|
||
|
||
wrapper.vm.menuModules = []
|
||
await wrapper.vm.handleSave()
|
||
|
||
const request = lastRequestOf('s_menu_module')
|
||
expect(request.deletes).toEqual([{ b_menu_id: 'otherdata', b_module_id: 'b_port' }])
|
||
expect(request.inserts).toBeUndefined()
|
||
})
|
||
|
||
it('目录菜单不绑定模块:保存时清空并删除既有关系行', async () => {
|
||
boundRows = [{ b_menu_id: 'base', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }]
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
wrapper.vm.menuSelectedId = 'base'
|
||
await flushPromises()
|
||
|
||
expect(wrapper.vm.menuModules).toEqual(['b_port'])
|
||
|
||
await wrapper.vm.handleSave()
|
||
|
||
const request = lastRequestOf('s_menu_module')
|
||
expect(request.deletes).toEqual([{ b_menu_id: 'base', b_module_id: 'b_port' }])
|
||
})
|
||
})
|
||
|
||
describe('菜单管理:入口权限点(s_power, menu.*)', () => {
|
||
/** 库中该菜单的入口权限点行(名称故意与菜单不同,用于验证保存会同步) */
|
||
function makePowerRow(over = {}) {
|
||
return {
|
||
b_id: 'menu.otherdata',
|
||
b_name: '其他数据',
|
||
b_i18n: 'menu.otherdata',
|
||
b_type: 'menu',
|
||
b_object_type: 'menu',
|
||
b_object_id: 'otherdata',
|
||
b_action: 'access',
|
||
b_canuse: 1,
|
||
b_xh: 10,
|
||
...over,
|
||
}
|
||
}
|
||
|
||
it('选中页面菜单时按 b_object_id 加载入口权限点基线', async () => {
|
||
powerRows = [makePowerRow()]
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
|
||
wrapper.vm.menuSelectedId = 'otherdata'
|
||
await flushPromises()
|
||
|
||
expect(loadDataApi).toHaveBeenCalledWith(
|
||
's_power',
|
||
"b_object_type = 'menu' and b_object_id = N'otherdata'",
|
||
'b_id ASC',
|
||
)
|
||
expect(wrapper.vm.menuPower_org?.b_id).toBe('menu.otherdata')
|
||
})
|
||
|
||
it('新增页面菜单:自动派生 menu.{编码} 权限点行', async () => {
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
wrapper.vm.menuSelectedId = 'otherdata'
|
||
await flushPromises()
|
||
|
||
// 改菜单名,保证有可提交的差异
|
||
wrapper.vm.maindata.b_name = '其他数据(新)'
|
||
await wrapper.vm.handleSave()
|
||
|
||
const request = lastRequestOf('s_power')
|
||
expect(request.key_field).toBe('b_id')
|
||
expect(request.inserts).toHaveLength(1)
|
||
expect(request.inserts[0]).toMatchObject({
|
||
b_id: 'menu.otherdata',
|
||
b_name: '其他数据(新)',
|
||
b_i18n: 'menu.otherdata',
|
||
b_type: 'menu',
|
||
b_object_type: 'menu',
|
||
b_object_id: 'otherdata',
|
||
b_action: 'access',
|
||
b_canuse: 1,
|
||
b_xh: 10,
|
||
})
|
||
})
|
||
|
||
it('保存页面菜单:入口权限点的名称跟随菜单同步更新', async () => {
|
||
powerRows = [makePowerRow({ b_name: '旧名称' })]
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
wrapper.vm.menuSelectedId = 'otherdata'
|
||
await flushPromises()
|
||
expect(wrapper.vm.menuPower_org?.b_name).toBe('旧名称')
|
||
|
||
// 菜单本身没有改动,只有权限点名称与菜单不一致 → 也应同步
|
||
await wrapper.vm.handleSave()
|
||
|
||
const request = lastRequestOf('s_power')
|
||
expect(request.updates).toHaveLength(1)
|
||
expect(request.updates[0]).toMatchObject({ b_id: 'menu.otherdata', b_name: '其他数据' })
|
||
})
|
||
|
||
it('目录菜单没有入口权限点:无差异时不提交任何保存请求', async () => {
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
wrapper.vm.menuSelectedId = 'base'
|
||
await flushPromises()
|
||
|
||
await wrapper.vm.handleSave()
|
||
|
||
// 目录不派生权限点;菜单 / 绑定 / 多语言都无差异 → 不调用保存接口
|
||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('删除页面菜单:入口权限点与授权行一并清理', async () => {
|
||
const { wrapper } = await mountPage(MenuManagement)
|
||
await flushPromises()
|
||
|
||
wrapper.vm.deleteMenu('otherdata')
|
||
await flushPromises()
|
||
await wrapper.vm.confirmState.onOk()
|
||
await flushPromises()
|
||
|
||
const requests = saveObjectApi.mock.calls.at(-1)[0]
|
||
expect(requests.find((request) => request.table === 's_power').deletes).toEqual([
|
||
{ b_id: 'menu.otherdata' },
|
||
])
|
||
const granted = requests.find((request) => request.table === 's_user_power')
|
||
expect(granted.key_field).toBe('b_power_id')
|
||
expect(granted.deletes).toEqual([{ b_power_id: 'menu.otherdata' }])
|
||
// 菜单主表最后删
|
||
expect(requests.at(-1)).toMatchObject({ table: 's_menu' })
|
||
})
|
||
})
|