Files
user-system/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx

470 lines
14 KiB
TypeScript
Raw Normal View History

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('审计日志')
})
})