gongxiangp2/components/department-dashboard/PermissionGroupsDashboard.test.tsx

526 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PermissionGroupsDashboard from './PermissionGroupsDashboard'
jest.mock('@gsap/react', () => ({ useGSAP: jest.fn() }))
jest.mock('gsap', () => ({ __esModule: true, default: { registerPlugin: jest.fn() } }))
jest.mock('next-auth/react', () => ({
useSession: () => ({
status: 'authenticated',
data: { user: { isSuperAdmin: true, name: '本地超级管理员' } },
}),
}))
jest.mock('./DepartmentShell', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
type PermissionFlags = {
canView: boolean
canDownload: boolean
canDownloadSource: boolean
canShare: boolean
canEditAssets: boolean
canAccessPrivate: boolean
}
type Payload = {
materialTypes: string[]
tree: Array<{
id: string
name: string
path: string
level: number
parentId: string | null
children: Payload['tree']
}>
groups: Array<{
slug: string
name: string
permissions: Record<string, PermissionFlags>
departments: Array<{ id?: string; path: string; name: string; status?: 'active' | 'invalid' }>
members: Array<{
userId: string
username: string
displayName: string
departmentPath: string | null
departmentName: string | null
}>
}>
}
function flags(overrides: Partial<PermissionFlags> = {}): PermissionFlags {
return {
canView: true,
canDownload: true,
canDownloadSource: false,
canShare: true,
canEditAssets: false,
canAccessPrivate: false,
...overrides,
}
}
function createPayload(): Payload {
return {
materialTypes: ['3D场景', '任务-详情'],
tree: [
{
id: 'root',
name: '江苏简尚品牌管理有限公司',
path: '江苏简尚品牌管理有限公司',
level: 1,
parentId: null,
children: [
{
id: 'ops',
name: '运营中心A',
path: '江苏简尚品牌管理有限公司/运营中心A',
level: 2,
parentId: 'root',
children: [],
},
],
},
],
groups: [
{
slug: 'operations',
name: '运营',
permissions: {
'3D场景': flags(),
'任务-详情': flags({ canDownloadSource: true }),
},
departments: [
{ path: '江苏简尚品牌管理有限公司/运营中心A', name: '运营中心A' },
],
members: [],
},
{
slug: 'visual',
name: '视觉',
permissions: {
'3D场景': flags(),
'任务-详情': flags({ canDownloadSource: true }),
},
departments: [],
members: [],
},
],
}
}
function jsonResponse(body: unknown, ok = true) {
return Promise.resolve({
ok,
json: async () => body,
})
}
function installFetchSequence(...responses: Array<ReturnType<typeof jsonResponse> | ((init?: RequestInit) => Promise<unknown>)>) {
const fetchMock = jest.fn()
fetchMock.mockImplementation((_input: RequestInfo | URL, init?: RequestInit) => {
const next = responses.shift()
if (!next) {
return Promise.reject(new Error('unexpected fetch call'))
}
return typeof next === 'function' ? next(init) : next
})
global.fetch = fetchMock as unknown as typeof fetch
return fetchMock
}
function pendingUntilAbort(label: string) {
return (init?: RequestInit) => {
const signal = init?.signal
if (!signal) {
return Promise.reject(new Error(`${label} missing AbortSignal`))
}
return new Promise((_, reject) => {
const abort = () => {
const error = new Error(`${label} aborted`)
error.name = 'AbortError'
reject(error)
}
if (signal.aborted) {
abort()
return
}
signal.addEventListener('abort', abort, { once: true })
})
}
}
async function renderDashboard() {
render(<PermissionGroupsDashboard />)
await screen.findByRole('navigation', { name: '权限组' })
}
function getTaskDetailCheckbox(label: string) {
return within(screen.getByRole('table')).getByRole('checkbox', { name: `任务-详情 ${label}` })
}
async function openDepartments() {
await userEvent.click(screen.getByRole('tab', { name: /部门/ }))
}
async function advanceAndFlush(ms: number) {
await act(async () => {
jest.advanceTimersByTime(ms)
await Promise.resolve()
})
}
describe('PermissionGroupsDashboard', () => {
afterEach(() => {
jest.useRealTimers()
jest.clearAllMocks()
})
it('在治理与设置里展示权限组和部门树', async () => {
installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
expect(within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /运营/ })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: '可见' })).toBeInTheDocument()
expect(screen.queryByRole('columnheader', { name: '分享' })).not.toBeInTheDocument()
expect(getTaskDetailCheckbox('编辑图片')).not.toBeChecked()
expect(getTaskDetailCheckbox('私密素材查看/下载')).not.toBeChecked()
await openDepartments()
expect(await screen.findByRole('navigation', { name: '花名册组织树' })).toBeInTheDocument()
expect(screen.getByText('已在本组')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '按人看权限' })).toHaveAttribute(
'href',
'/workspaces/governance/permissions?view=people',
)
})
it('已在别组的部门会标明占用,不能再加入当前组', async () => {
installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
await userEvent.click(
within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /视觉/ }),
)
await openDepartments()
expect(await screen.findByText('已在「运营」')).toBeInTheDocument()
expect(screen.getByText('占用')).toBeInTheDocument()
})
it('可以把部门从组里拿掉', async () => {
const fetchMock = installFetchSequence(
jsonResponse(createPayload()),
jsonResponse({ ok: true }),
jsonResponse(createPayload()),
)
await renderDashboard()
await openDepartments()
await userEvent.click(await screen.findByRole('button', { name: '移除运营中心A' }))
expect(fetchMock).toHaveBeenCalledWith(
'/api/organization/permission-groups',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
action: 'remove-department',
slug: 'operations',
organizationUnitId: 'ops',
path: '江苏简尚品牌管理有限公司/运营中心A',
}),
}),
)
})
it('失效的旧关联不会当作有效部门,并能按关联 ID 清理', async () => {
const payload = createPayload()
payload.groups[0].departments = [{ id: 'stale', path: '公司/旧部门', name: '旧部门', status: 'invalid' }]
const fetchMock = installFetchSequence(jsonResponse(payload), jsonResponse({ ok: true }), jsonResponse(createPayload()))
await renderDashboard()
await openDepartments()
expect(await screen.findByText('关联已失效')).toBeInTheDocument()
expect(screen.getAllByText(/0 个部门 · 1 失效/).length).toBeGreaterThan(0)
await userEvent.click(screen.getByRole('button', { name: '移除已加入的旧部门' }))
const write = fetchMock.mock.calls.find(([, init]) => init?.method === 'PUT')
expect(JSON.parse(write?.[1].body)).toMatchObject({ action: 'remove-department', associationId: 'stale', slug: 'operations' })
})
it('save-pack 成功后只发起首屏 GET 和一次 PUT,并直接应用后端返回的标准化权限', async () => {
const nextPermissions = {
'3D场景': flags(),
'任务-详情': flags({
canDownloadSource: true,
canEditAssets: true,
canAccessPrivate: true,
}),
}
const fetchMock = installFetchSequence(
jsonResponse(createPayload()),
jsonResponse({ ok: true, permissions: nextPermissions }),
)
await renderDashboard()
const user = userEvent.setup()
const editAssets = getTaskDetailCheckbox('编辑图片')
const privateAccess = getTaskDetailCheckbox('私密素材查看/下载')
await user.click(editAssets)
await user.click(privateAccess)
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled())
expect(fetchMock.mock.calls[1]).toEqual([
'/api/organization/permission-groups',
expect.objectContaining({
method: 'PUT',
body: expect.stringContaining('"action":"save-pack"'),
}),
])
expect(screen.getByText('已保存')).toBeInTheDocument()
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(getTaskDetailCheckbox('私密素材查看/下载')).toBeChecked()
})
it('save-pack 超时后退出保存态,保留 draft,且不会自动重发 PUT', async () => {
jest.useFakeTimers()
const fetchMock = installFetchSequence(
jsonResponse(createPayload()),
pendingUntilAbort('save-pack PUT'),
)
await renderDashboard()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
await user.click(getTaskDetailCheckbox('私密素材查看/下载'))
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
expect(screen.getByText('保存中…')).toBeInTheDocument()
await advanceAndFlush(15_100)
await waitFor(() => expect(screen.getByRole('button', { name: '保存这组权限' })).toBeEnabled())
expect(getTaskDetailCheckbox('私密素材查看/下载')).toBeChecked()
expect(screen.getByText(/结果尚未确认/)).toBeInTheDocument()
expect(screen.getByText(/刷新.*列表.*核对/)).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(2)
await advanceAndFlush(20_000)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('其他动作 PUT 成功后即退出保存态,刷新挂起再超时时保留成功提示且不给出再次保存暗示', async () => {
jest.useFakeTimers()
const fetchMock = installFetchSequence(
jsonResponse(createPayload()),
jsonResponse({ ok: true }),
pendingUntilAbort('permission-groups refresh GET'),
)
await renderDashboard()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
await user.click(screen.getByRole('tab', { name: /部门/ }))
await user.click(await screen.findByRole('button', { name: '移除运营中心A' }))
expect(await screen.findByText('已从本组移除 运营中心A')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '保存这组权限' })).not.toBeInTheDocument()
expect(screen.queryByText('保存中…')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '刷新中…' })).toBeDisabled()
await advanceAndFlush(15_100)
expect(await screen.findByText(/已保存,但列表刷新失败/)).toBeInTheDocument()
expect(screen.getByText(/不用再次保存/)).toBeInTheDocument()
expect(screen.getByText('已从本组移除 运营中心A')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1)
})
it('save-pack 失败后保留当前勾选,不自动刷新列表', async () => {
const fetchMock = installFetchSequence(
jsonResponse(createPayload()),
jsonResponse({ error: '保存权限组失败' }, false),
)
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
expect(await screen.findByText('保存权限组失败')).toBeInTheDocument()
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeEnabled()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('在页签间切换会保留权限草稿,撤销后恢复原始权限', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(screen.getByRole('tab', { name: /人员/ }))
expect(screen.getByText('1 项未保存')).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: /素材权限/ }))
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
await user.click(screen.getByRole('button', { name: '撤销修改' }))
expect(getTaskDetailCheckbox('编辑图片')).not.toBeChecked()
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled()
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('改回原始勾选后不再提示未保存,首个修改不影响其他素材类型', async () => {
installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
expect(within(screen.getByRole('table')).getByRole('checkbox', { name: '3D场景 可见' })).toBeChecked()
await user.click(getTaskDetailCheckbox('编辑图片'))
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeDisabled()
})
it('拒绝切换其他组时保留草稿且不会发出写请求', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false)
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(within(screen.getByRole('navigation', { name: '权限组' })).getByRole('button', { name: /视觉/ }))
expect(confirm).toHaveBeenCalledTimes(1)
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(fetchMock).toHaveBeenCalledTimes(1)
confirm.mockRestore()
})
it('部门搜索自动显示折叠树内的匹配部门及祖先', async () => {
const payload = createPayload()
payload.tree[0].children[0].children = [{ id: 'nested', name: '电商设计部', path: '江苏简尚品牌管理有限公司/运营中心A/电商设计部', parentId: 'ops', level: 3, children: [] }]
installFetchSequence(jsonResponse(payload))
await renderDashboard()
await openDepartments()
expect(screen.queryByText('电商设计部')).not.toBeInTheDocument()
await userEvent.type(screen.getByLabelText('查找并加入部门'), '电商设计')
expect(screen.getByText('电商设计部')).toBeInTheDocument()
expect(screen.getByText('随 运营中心A · 运营')).toBeInTheDocument()
})
it('组名失焦不写入,明确保存组名后保留素材权限草稿', async () => {
const renamed = createPayload()
renamed.groups[0].name = '运营新组'
const fetchMock = installFetchSequence(jsonResponse(createPayload()), jsonResponse({ ok: true }), jsonResponse(renamed))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(screen.getByRole('tab', { name: '组设置' }))
await user.clear(screen.getByLabelText('组名'))
await user.type(screen.getByLabelText('组名'), '运营新组')
await user.tab()
expect(fetchMock).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: '保存组名' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
await user.click(screen.getByRole('tab', { name: /素材权限/ }))
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ action: 'rename', slug: 'operations', name: '运营新组' }))
})
it('管理部门后的刷新保留未保存的素材权限', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()), jsonResponse({ ok: true }), jsonResponse(createPayload()))
await renderDashboard()
const user = userEvent.setup()
await user.click(getTaskDetailCheckbox('私密素材查看/下载'))
await user.click(screen.getByRole('tab', { name: /部门/ }))
await user.click(screen.getByRole('button', { name: '移除运营中心A' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3))
await user.click(screen.getByRole('tab', { name: /素材权限/ }))
expect(getTaskDetailCheckbox('私密素材查看/下载')).toBeChecked()
expect(screen.getByRole('button', { name: '保存这组权限' })).toBeEnabled()
})
it('页签支持方向键切换并移动焦点', async () => {
installFetchSequence(jsonResponse(createPayload()))
await renderDashboard()
screen.getByRole('tab', { name: /素材权限/ }).focus()
await userEvent.keyboard('{ArrowRight}')
expect(screen.getByRole('tab', { name: /部门/ })).toHaveFocus()
expect(screen.getByRole('tab', { name: /部门/ })).toHaveAttribute('aria-selected', 'true')
await userEvent.keyboard('{End}')
expect(screen.getByRole('tab', { name: '组设置' })).toHaveFocus()
})
it('人员搜索失败显示可恢复错误,清空后移除错误状态', async () => {
installFetchSequence(jsonResponse(createPayload()), jsonResponse({ error: 'unavailable' }, false))
await renderDashboard()
const user = userEvent.setup()
await user.click(screen.getByRole('tab', { name: /人员/ }))
await user.type(screen.getByLabelText('搜人加入本组'), '测试')
expect(await screen.findByText('搜索失败,请修改关键词或重试。')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '清空人员搜索' }))
expect(screen.queryByText('搜索失败,请修改关键词或重试。')).not.toBeInTheDocument()
})
it('较早的人员搜索响应不会覆盖当前搜索结果', async () => {
jest.useFakeTimers()
let resolveFirst: (value: unknown) => void = () => {}
const firstRequest = new Promise(resolve => { resolveFirst = resolve })
installFetchSequence(jsonResponse(createPayload()), () => firstRequest, jsonResponse({ candidates: [{ userId: 'new', displayName: '当前候选', username: 'new', departmentName: '设计部', departmentPath: null }] }))
await renderDashboard()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
await user.click(screen.getByRole('tab', { name: /人员/ }))
await user.type(screen.getByLabelText('搜人加入本组'), 'first')
await advanceAndFlush(300)
await user.clear(screen.getByLabelText('搜人加入本组'))
await user.type(screen.getByLabelText('搜人加入本组'), 'second')
await advanceAndFlush(300)
expect(screen.getByText('当前候选')).toBeInTheDocument()
await act(async () => { resolveFirst({ ok: true, json: async () => ({ candidates: [{ userId: 'old', displayName: '过期候选', username: 'old' }] }) }); await Promise.resolve() })
expect(screen.getByText('当前候选')).toBeInTheDocument()
expect(screen.queryByText('过期候选')).not.toBeInTheDocument()
})
it('本页链接取消离开时保留素材权限草稿', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false)
await renderDashboard()
await userEvent.click(getTaskDetailCheckbox('编辑图片'))
expect(fireEvent.click(screen.getByRole('link', { name: '按人看权限' }))).toBe(false)
expect(confirm).toHaveBeenCalledTimes(1)
expect(getTaskDetailCheckbox('编辑图片')).toBeChecked()
expect(fetchMock).toHaveBeenCalledTimes(1)
confirm.mockRestore()
})
it('权限请求处理中阻止本页链接离开', async () => {
jest.useFakeTimers()
const fetchMock = installFetchSequence(jsonResponse(createPayload()), pendingUntilAbort('save-pack PUT'))
await renderDashboard()
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })
await user.click(getTaskDetailCheckbox('编辑图片'))
await user.click(screen.getByRole('button', { name: '保存这组权限' }))
const link = screen.getByRole('link', { name: '按人看权限' })
expect(link).toHaveAttribute('aria-disabled', 'true')
expect(fireEvent.click(link)).toBe(false)
expect(fetchMock).toHaveBeenCalledTimes(2)
await advanceAndFlush(15_100)
})
it('创建新组与本页链接都保护未保存的组名', async () => {
const fetchMock = installFetchSequence(jsonResponse(createPayload()))
const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false)
await renderDashboard()
const user = userEvent.setup()
await user.click(screen.getByRole('tab', { name: '组设置' }))
await user.type(screen.getByLabelText('组名'), '待保存')
expect(fireEvent.click(screen.getByRole('link', { name: '按人看权限' }))).toBe(false)
await user.click(screen.getByRole('button', { name: '新建' }))
await user.type(screen.getByLabelText('新建权限组'), '新组')
await user.click(screen.getByRole('button', { name: '创建' }))
expect(confirm).toHaveBeenCalledTimes(2)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(screen.getByLabelText('组名')).toHaveValue('运营待保存')
confirm.mockRestore()
})
})