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 }) =>
{children}
,
}))
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
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 {
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 | ((init?: RequestInit) => Promise)>) {
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()
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()
})
})