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() }) })