470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
|
|
|
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
|
|
import { AdminLayout } from './AdminLayout'
|
|
import styles from './AdminLayout.module.css'
|
|
|
|
const logoutMock = vi.fn(async () => {})
|
|
|
|
function flattenChildren(children: ReactNode): string {
|
|
if (children === null || children === undefined || typeof children === 'boolean') {
|
|
return ''
|
|
}
|
|
|
|
if (typeof children === 'string' || typeof children === 'number') {
|
|
return String(children)
|
|
}
|
|
|
|
if (Array.isArray(children)) {
|
|
return children.map(flattenChildren).join(' ').trim()
|
|
}
|
|
|
|
if (typeof children === 'object' && 'props' in children) {
|
|
return flattenChildren((children as { props?: { children?: ReactNode } }).props?.children)
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
vi.mock('antd', async () => {
|
|
const React = await import('react')
|
|
|
|
type MenuItem = {
|
|
key?: string
|
|
label?: ReactNode
|
|
children?: MenuItem[]
|
|
type?: string
|
|
onClick?: () => void
|
|
}
|
|
|
|
const Layout = Object.assign(
|
|
({
|
|
children,
|
|
className,
|
|
}: {
|
|
children?: ReactNode
|
|
className?: string
|
|
}) => (
|
|
<div data-testid="layout" className={className}>
|
|
{children}
|
|
</div>
|
|
),
|
|
{
|
|
Sider: ({
|
|
children,
|
|
className,
|
|
}: {
|
|
children?: ReactNode
|
|
className?: string
|
|
}) => (
|
|
<aside data-testid="sider" className={className}>
|
|
{children}
|
|
</aside>
|
|
),
|
|
Header: ({
|
|
children,
|
|
className,
|
|
}: {
|
|
children?: ReactNode
|
|
className?: string
|
|
}) => (
|
|
<header data-testid="header" className={className}>
|
|
{children}
|
|
</header>
|
|
),
|
|
Content: ({
|
|
children,
|
|
className,
|
|
}: {
|
|
children?: ReactNode
|
|
className?: string
|
|
}) => (
|
|
<main data-testid="content" className={className}>
|
|
{children}
|
|
</main>
|
|
),
|
|
},
|
|
)
|
|
|
|
function Menu({
|
|
items = [],
|
|
onClick,
|
|
selectedKeys = [],
|
|
defaultOpenKeys = [],
|
|
}: {
|
|
items?: MenuItem[]
|
|
onClick?: (info: { key: string }) => void
|
|
selectedKeys?: string[]
|
|
defaultOpenKeys?: string[]
|
|
}) {
|
|
const [openKeys, setOpenKeys] = React.useState((defaultOpenKeys ?? []).map(String))
|
|
|
|
React.useEffect(() => {
|
|
setOpenKeys((defaultOpenKeys ?? []).map(String))
|
|
}, [defaultOpenKeys])
|
|
|
|
const renderItem = (item: MenuItem): ReactNode => {
|
|
if (item.type === 'divider') {
|
|
return <hr key="divider" />
|
|
}
|
|
|
|
const key = String(item.key ?? flattenChildren(item.label))
|
|
const label = flattenChildren(item.label)
|
|
const hasChildren = Boolean(item.children?.length)
|
|
|
|
return (
|
|
<div key={key}>
|
|
<button
|
|
type="button"
|
|
data-testid={`menu-item-${key}`}
|
|
onClick={() => {
|
|
if (hasChildren) {
|
|
setOpenKeys((current) => (
|
|
current.includes(key)
|
|
? current.filter((value) => value !== key)
|
|
: [...current, key]
|
|
))
|
|
return
|
|
}
|
|
|
|
onClick?.({ key })
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
{hasChildren && openKeys.includes(key) ? item.children?.map(renderItem) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-testid="menu"
|
|
data-open-keys={openKeys.join(',')}
|
|
data-selected-keys={(selectedKeys ?? []).join(',')}
|
|
>
|
|
{items.map((item) => renderItem(item as MenuItem))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Dropdown({
|
|
children,
|
|
menu,
|
|
}: {
|
|
children?: ReactNode
|
|
menu?: { items?: MenuItem[] }
|
|
}) {
|
|
const [open, setOpen] = React.useState(false)
|
|
|
|
return (
|
|
<div>
|
|
<button type="button" data-testid="dropdown-trigger" onClick={() => setOpen((value) => !value)}>
|
|
{children}
|
|
</button>
|
|
{open ? (
|
|
<div data-testid="dropdown-menu">
|
|
{menu?.items?.map((item, index) => {
|
|
if (!item || item.type === 'divider') {
|
|
return <hr key={`dropdown-divider-${index}`} />
|
|
}
|
|
|
|
const key = String(item.key ?? index)
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
data-testid={`dropdown-item-${key}`}
|
|
onClick={() => {
|
|
item.onClick?.()
|
|
setOpen(false)
|
|
}}
|
|
>
|
|
{flattenChildren(item.label)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return {
|
|
Avatar: ({
|
|
src,
|
|
style,
|
|
icon,
|
|
size,
|
|
}: {
|
|
src?: string | null
|
|
style?: { backgroundColor?: string }
|
|
icon?: ReactNode
|
|
size?: number
|
|
}) => (
|
|
<div
|
|
data-testid="avatar"
|
|
data-src={src ?? ''}
|
|
data-background={style?.backgroundColor ?? ''}
|
|
data-size={String(size ?? '')}
|
|
>
|
|
{src ? <img alt="avatar" src={src} /> : icon}
|
|
</div>
|
|
),
|
|
Button: ({
|
|
children,
|
|
icon,
|
|
onClick,
|
|
htmlType,
|
|
...props
|
|
}: {
|
|
children?: ReactNode
|
|
icon?: ReactNode
|
|
onClick?: () => void
|
|
htmlType?: 'button' | 'submit' | 'reset'
|
|
[key: string]: unknown
|
|
}) => (
|
|
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
|
|
{children ?? icon}
|
|
</button>
|
|
),
|
|
Drawer: ({
|
|
open,
|
|
title,
|
|
children,
|
|
onClose,
|
|
}: {
|
|
open?: boolean
|
|
title?: ReactNode
|
|
children?: ReactNode
|
|
onClose?: () => void
|
|
}) => (
|
|
open ? (
|
|
<div data-testid="drawer">
|
|
<div data-testid="drawer-title">{title}</div>
|
|
<button type="button" onClick={onClose}>close drawer</button>
|
|
{children}
|
|
</div>
|
|
) : null
|
|
),
|
|
Dropdown,
|
|
Layout,
|
|
Menu,
|
|
Spin: ({
|
|
tip,
|
|
size,
|
|
children,
|
|
}: {
|
|
tip?: ReactNode
|
|
size?: string
|
|
children?: ReactNode
|
|
}) => (
|
|
<div aria-busy="true" data-testid="spin" data-tip={flattenChildren(tip)} data-size={size}>
|
|
{children}
|
|
</div>
|
|
),
|
|
}
|
|
})
|
|
|
|
vi.mock('@ant-design/icons', () => ({
|
|
ApiOutlined: () => <span>api-icon</span>,
|
|
DashboardOutlined: () => <span>dashboard-icon</span>,
|
|
FileTextOutlined: () => <span>file-text-icon</span>,
|
|
LogoutOutlined: () => <span>logout-icon</span>,
|
|
MenuFoldOutlined: () => <span>menu-fold-icon</span>,
|
|
MenuOutlined: () => <span>menu-icon</span>,
|
|
MenuUnfoldOutlined: () => <span>menu-unfold-icon</span>,
|
|
SafetyOutlined: () => <span>safety-icon</span>,
|
|
SettingOutlined: () => <span>setting-icon</span>,
|
|
UserOutlined: () => <span>user-icon</span>,
|
|
}))
|
|
|
|
const baseAuthContextValue: AuthContextValue = {
|
|
user: {
|
|
id: 1,
|
|
username: 'admin',
|
|
email: 'admin@example.com',
|
|
phone: '13800138000',
|
|
nickname: 'admin-nickname',
|
|
avatar: '',
|
|
status: 1,
|
|
},
|
|
roles: [],
|
|
isAdmin: true,
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
onLoginSuccess: async () => {},
|
|
logout: () => logoutMock(),
|
|
refreshUser: async () => {},
|
|
}
|
|
|
|
function setWindowWidth(width: number) {
|
|
Object.defineProperty(window, 'innerWidth', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: width,
|
|
})
|
|
}
|
|
|
|
function renderAdminLayout(
|
|
authContextValue: Partial<AuthContextValue> = {},
|
|
initialEntry: string = '/profile/security',
|
|
layoutChildren?: ReactNode,
|
|
) {
|
|
const value: AuthContextValue = {
|
|
...baseAuthContextValue,
|
|
...authContextValue,
|
|
}
|
|
|
|
return render(
|
|
<MemoryRouter initialEntries={[initialEntry]}>
|
|
<AuthContext.Provider value={value}>
|
|
<Routes>
|
|
<Route path="/" element={<AdminLayout>{layoutChildren}</AdminLayout>}>
|
|
<Route path="dashboard" element={<div>Dashboard Page</div>} />
|
|
<Route path="users" element={<div>Users Page</div>} />
|
|
<Route path="roles" element={<div>Roles Page</div>} />
|
|
<Route path="permissions" element={<div>Permissions Page</div>} />
|
|
<Route path="logs/login" element={<div>Login Logs Page</div>} />
|
|
<Route path="logs/operation" element={<div>Operation Logs Page</div>} />
|
|
<Route path="webhooks" element={<div>Webhooks Page</div>} />
|
|
<Route path="import-export" element={<div>Import Export Page</div>} />
|
|
<Route path="profile" element={<div>Profile Page</div>} />
|
|
<Route path="profile/security" element={<div>Security Page</div>} />
|
|
</Route>
|
|
</Routes>
|
|
</AuthContext.Provider>
|
|
</MemoryRouter>,
|
|
)
|
|
}
|
|
|
|
describe('AdminLayout', () => {
|
|
beforeEach(() => {
|
|
logoutMock.mockClear()
|
|
setWindowWidth(1280)
|
|
})
|
|
|
|
afterEach(() => {
|
|
setWindowWidth(1280)
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('shows a loading state while the session is restoring', () => {
|
|
renderAdminLayout({ isLoading: true })
|
|
|
|
expect(screen.getByTestId('spin')).toHaveAttribute('data-tip')
|
|
expect(screen.queryByText('Security Page')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renders desktop admin navigation, breadcrumbs, collapse state, dropdown actions, and mobile drawer navigation', async () => {
|
|
const user = userEvent.setup()
|
|
const { container } = renderAdminLayout({ isAdmin: true }, '/profile/security')
|
|
|
|
expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('用户管理系统')
|
|
expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('admin-nickname')
|
|
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile')
|
|
expect(screen.getByText('Security Page')).toBeInTheDocument()
|
|
|
|
const breadcrumbLink = container.querySelector(`.${styles.breadcrumbLink}`)
|
|
expect(breadcrumbLink).not.toBeNull()
|
|
await user.click(breadcrumbLink as HTMLElement)
|
|
await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument())
|
|
|
|
await user.click(screen.getByTestId('menu-item-access-control'))
|
|
await user.click(screen.getByTestId('menu-item-/users'))
|
|
await waitFor(() => expect(screen.getByText('Users Page')).toBeInTheDocument())
|
|
|
|
await user.click(screen.getByTestId('dropdown-trigger'))
|
|
await user.click(screen.getByTestId('dropdown-item-security'))
|
|
await waitFor(() => expect(screen.getByText('Security Page')).toBeInTheDocument())
|
|
|
|
await user.click(screen.getByTestId('dropdown-trigger'))
|
|
await user.click(screen.getByTestId('dropdown-item-profile'))
|
|
await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument())
|
|
|
|
await user.click(screen.getByTestId('dropdown-trigger'))
|
|
await user.click(screen.getByTestId('dropdown-item-logout'))
|
|
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1))
|
|
|
|
const collapseButton = screen.getByText('menu-fold-icon').closest('button')
|
|
expect(collapseButton).not.toBeNull()
|
|
await user.click(collapseButton as HTMLButtonElement)
|
|
|
|
expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('UMS')
|
|
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', '')
|
|
expect(screen.getByText('menu-unfold-icon')).toBeInTheDocument()
|
|
|
|
await act(async () => {
|
|
setWindowWidth(375)
|
|
window.dispatchEvent(new Event('resize'))
|
|
})
|
|
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'menu-icon' })).toBeInTheDocument())
|
|
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
|
|
|
|
const drawer = screen.getByTestId('drawer')
|
|
expect(within(drawer).getByTestId('drawer-title')).toHaveTextContent('UMS')
|
|
|
|
await user.click(within(drawer).getByTestId('menu-item-/dashboard'))
|
|
await waitFor(() => expect(screen.getByText('Dashboard Page')).toBeInTheDocument())
|
|
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('renders the reduced mobile menu for non-admin users and uses avatar and username fallbacks correctly', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
setWindowWidth(375)
|
|
|
|
const { container } = renderAdminLayout(
|
|
{
|
|
isAdmin: false,
|
|
user: {
|
|
id: 2,
|
|
username: 'operator-name',
|
|
email: 'operator@example.com',
|
|
phone: '',
|
|
nickname: '',
|
|
avatar: 'https://example.com/avatar.png',
|
|
status: 1,
|
|
},
|
|
},
|
|
'/profile',
|
|
)
|
|
|
|
expect(screen.queryByTestId('menu-item-access-control')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('menu-item-logs')).not.toBeInTheDocument()
|
|
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile')
|
|
expect(screen.getByTestId('avatar')).toHaveAttribute('data-src', 'https://example.com/avatar.png')
|
|
expect(screen.getByTestId('avatar')).toHaveAttribute('data-background', '')
|
|
expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('operator-name')
|
|
|
|
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
|
|
const drawer = screen.getByTestId('drawer')
|
|
await user.click(within(drawer).getByTestId('menu-item-/webhooks'))
|
|
|
|
await waitFor(() => expect(screen.getByText('Webhooks Page')).toBeInTheDocument())
|
|
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => {
|
|
const { container } = renderAdminLayout(
|
|
{
|
|
user: null,
|
|
},
|
|
'/logs/login',
|
|
<div>Injected Layout Content</div>,
|
|
)
|
|
|
|
expect(screen.getByText('Injected Layout Content')).toBeInTheDocument()
|
|
expect(screen.queryByText('Login Logs Page')).not.toBeInTheDocument()
|
|
expect(container.querySelector(`.${styles.userName}`)?.textContent?.trim().length).toBeGreaterThan(0)
|
|
|
|
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-selected-keys', '/logs/login')
|
|
expect(container.querySelector(`.${styles.breadcrumb}`)).toHaveTextContent('审计日志')
|
|
})
|
|
})
|