feat: 新增监控页面、部门管理占位与单元测试

- 新增系统监控模块(在线用户、定时任务、数据监控、服务器监控、缓存监控)
- 新增部门管理占位页面
- 路由注册新增模块与懒加载
- DefaultLayout 侧边菜单与布局优化
- 新增前端单元测试与后端 RoleUpdateRequest 测试
This commit is contained in:
张翔
2026-05-06 14:18:17 +08:00
committed by zhangxiang
parent e8f51309e5
commit 3e8e14d662
30 changed files with 2071 additions and 4 deletions
@@ -0,0 +1,68 @@
package cn.novalon.manage.sys.dto.request;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class RoleUpdateRequestTest {
private static Validator validator;
@BeforeAll
static void setUp() {
try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
validator = factory.getValidator();
}
}
@Test
void testRoleSortGreaterThanZero() {
RoleUpdateRequest request = new RoleUpdateRequest();
request.setRoleSort(1);
var violations = validator.validateProperty(request, "roleSort");
assertTrue(violations.isEmpty(), "roleSort=1 should pass validation");
}
@Test
void testRoleSortZeroFails() {
RoleUpdateRequest request = new RoleUpdateRequest();
request.setRoleSort(0);
var violations = validator.validateProperty(request, "roleSort");
assertFalse(violations.isEmpty(), "roleSort=0 should fail validation");
assertEquals("显示顺序必须大于0", violations.iterator().next().getMessage());
}
@Test
void testRoleSortNegativeFails() {
RoleUpdateRequest request = new RoleUpdateRequest();
request.setRoleSort(-1);
var violations = validator.validateProperty(request, "roleSort");
assertFalse(violations.isEmpty(), "roleSort=-1 should fail validation");
assertEquals("显示顺序必须大于0", violations.iterator().next().getMessage());
}
@Test
void testRoleSortNullPasses() {
RoleUpdateRequest request = new RoleUpdateRequest();
request.setRoleSort(null);
var violations = validator.validateProperty(request, "roleSort");
assertTrue(violations.isEmpty(), "roleSort=null should pass validation (optional field)");
}
@Test
void testRoleSortLargeValue() {
RoleUpdateRequest request = new RoleUpdateRequest();
request.setRoleSort(Integer.MAX_VALUE);
var violations = validator.validateProperty(request, "roleSort");
assertTrue(violations.isEmpty(), "roleSort=MAX_VALUE should pass validation");
}
}
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { FileInfo } from '@/api/file'
vi.mock('@/utils/request', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
import request from '@/utils/request'
const mockFiles: FileInfo[] = [
{
id: 1,
fileName: 'report.pdf',
filePath: '/uploads/report.pdf',
fileSize: '1024000',
fileType: 'application/pdf',
storageType: 'local',
createBy: 'admin',
createdAt: '2026-01-01T00:00:00',
},
{
id: 2,
fileName: 'photo.jpg',
filePath: '/uploads/photo.jpg',
fileSize: '512000',
fileType: 'image/jpeg',
storageType: 'local',
createBy: 'user1',
createdAt: '2026-01-02T00:00:00',
},
{
id: 3,
fileName: 'data.xlsx',
filePath: '/uploads/data.xlsx',
fileSize: '256000',
fileType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
storageType: 'local',
createBy: 'admin',
createdAt: '2026-01-03T00:00:00',
},
]
describe('fileApi.getPage (client-side pagination)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return paginated results without filters', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockFiles)
const { fileApi } = await import('@/api/file')
const result = await fileApi.getPage({ page: 0, size: 2 })
expect(result.content).toHaveLength(2)
expect(result.totalElements).toBe(3)
expect(result.totalPages).toBe(2)
expect(result.first).toBe(true)
expect(result.last).toBe(false)
})
it('should return second page correctly', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockFiles)
const { fileApi } = await import('@/api/file')
const result = await fileApi.getPage({ page: 1, size: 2 })
expect(result.content).toHaveLength(1)
expect(result.content[0].id).toBe(3)
expect(result.first).toBe(false)
expect(result.last).toBe(true)
})
it('should filter by fileName', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockFiles)
const { fileApi } = await import('@/api/file')
const result = await fileApi.getPage({ page: 0, size: 10, fileName: 'photo' })
expect(result.content).toHaveLength(1)
expect(result.content[0].fileName).toBe('photo.jpg')
})
it('should filter by fileType', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockFiles)
const { fileApi } = await import('@/api/file')
const result = await fileApi.getPage({ page: 0, size: 10, fileType: 'image/jpeg' })
expect(result.content).toHaveLength(1)
expect(result.content[0].fileName).toBe('photo.jpg')
})
it('should return empty when no match', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockFiles)
const { fileApi } = await import('@/api/file')
const result = await fileApi.getPage({ page: 0, size: 10, fileName: 'nonexistent' })
expect(result.content).toHaveLength(0)
expect(result.totalElements).toBe(0)
})
it('should handle empty data', async () => {
vi.mocked(request.get).mockResolvedValueOnce([])
const { fileApi } = await import('@/api/file')
const result = await fileApi.getPage({ page: 0, size: 10 })
expect(result.content).toHaveLength(0)
expect(result.totalElements).toBe(0)
expect(result.totalPages).toBe(0)
})
})
describe('FileInfo interface', () => {
it('should have correct field names matching backend response', () => {
const file: FileInfo = {
id: 1,
fileName: 'test.pdf',
filePath: '/uploads/test.pdf',
fileSize: '1024',
fileType: 'application/pdf',
storageType: 'local',
createBy: 'admin',
createdAt: '2026-01-01T00:00:00',
}
expect(file.fileName).toBe('test.pdf')
expect(file.createBy).toBe('admin')
expect(file.fileType).toBe('application/pdf')
expect(file.fileSize).toBe('1024')
})
})
@@ -0,0 +1,192 @@
import { describe, it, expect } from 'vitest'
import type { RawMenuItem } from '@/api/menu'
const menuTypeMap: Record<string, 'directory' | 'menu' | 'button'> = {
M: 'directory',
C: 'menu',
F: 'button',
}
function buildPath(raw: RawMenuItem): string {
if (raw.menuType === 'M') return ''
if (raw.menuType === 'F') return ''
const perm = raw.perms || ''
const pathMap: Record<string, string> = {
'system:user:list': '/users',
'system:role:list': '/roles',
'system:menu:list': '/menus',
'system:dept:list': '/sys/dept',
'system:dict:list': '/dict',
'system:config:list': '/sys/config',
'system:notice:list': '/notice',
'system:file:list': '/files',
'audit:login:list': '/loginlog',
'audit:login-log:list': '/loginlog',
'audit:operation:list': '/oplog',
'audit:operation-log:list': '/oplog',
'audit:exception:list': '/exceptionlog',
'audit:exception-log:list': '/exceptionlog',
'monitor:online:list': '/monitor/online',
'monitor:job:list': '/monitor/job',
'monitor:data:list': '/monitor/data',
'monitor:server:list': '/monitor/server',
'monitor:cache:list': '/monitor/cache',
}
return pathMap[perm] || ''
}
function inferIcon(raw: RawMenuItem): string {
const perm = raw.perms || ''
const iconMap: Record<string, string> = {
'system:user:list': 'user',
'system:role:list': 'role',
'system:menu:list': 'menu',
'system:dept:list': 'menu',
'system:dict:list': 'dict',
'system:config:list': 'config',
'system:notice:list': 'notice',
'system:file:list': 'file',
'audit:login:list': 'loginlog',
'audit:login-log:list': 'loginlog',
'audit:operation:list': 'oplog',
'audit:operation-log:list': 'oplog',
'audit:exception:list': 'exceptionlog',
'audit:exception-log:list': 'exceptionlog',
'monitor:online:list': 'user',
'monitor:job:list': 'menu',
'monitor:data:list': 'config',
'monitor:server:list': 'config',
'monitor:cache:list': 'config',
}
return iconMap[perm] || ''
}
function makeRaw(overrides: Partial<RawMenuItem> = {}): RawMenuItem {
return {
id: '1',
createBy: null,
updateBy: null,
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
deletedAt: null,
menuName: '测试菜单',
parentId: '0',
orderNum: 1,
menuType: 'C',
perms: null,
component: null,
status: 0,
children: [],
...overrides,
}
}
describe('menu utils', () => {
describe('buildPath', () => {
it('should return empty string for directory type (M)', () => {
const raw = makeRaw({ menuType: 'M' })
expect(buildPath(raw)).toBe('')
})
it('should return empty string for button type (F)', () => {
const raw = makeRaw({ menuType: 'F' })
expect(buildPath(raw)).toBe('')
})
it('should map system:user:list to /users', () => {
const raw = makeRaw({ menuType: 'C', perms: 'system:user:list' })
expect(buildPath(raw)).toBe('/users')
})
it('should map system:role:list to /roles', () => {
const raw = makeRaw({ menuType: 'C', perms: 'system:role:list' })
expect(buildPath(raw)).toBe('/roles')
})
it('should map system:dept:list to /sys/dept', () => {
const raw = makeRaw({ menuType: 'C', perms: 'system:dept:list' })
expect(buildPath(raw)).toBe('/sys/dept')
})
it('should map audit:login:list to /loginlog', () => {
const raw = makeRaw({ menuType: 'C', perms: 'audit:login:list' })
expect(buildPath(raw)).toBe('/loginlog')
})
it('should map audit:login-log:list to /loginlog (alternate key)', () => {
const raw = makeRaw({ menuType: 'C', perms: 'audit:login-log:list' })
expect(buildPath(raw)).toBe('/loginlog')
})
it('should map audit:operation:list to /oplog', () => {
const raw = makeRaw({ menuType: 'C', perms: 'audit:operation:list' })
expect(buildPath(raw)).toBe('/oplog')
})
it('should map audit:exception:list to /exceptionlog', () => {
const raw = makeRaw({ menuType: 'C', perms: 'audit:exception:list' })
expect(buildPath(raw)).toBe('/exceptionlog')
})
it('should map monitor:online:list to /monitor/online', () => {
const raw = makeRaw({ menuType: 'C', perms: 'monitor:online:list' })
expect(buildPath(raw)).toBe('/monitor/online')
})
it('should map monitor:cache:list to /monitor/cache', () => {
const raw = makeRaw({ menuType: 'C', perms: 'monitor:cache:list' })
expect(buildPath(raw)).toBe('/monitor/cache')
})
it('should return empty string for unknown permission', () => {
const raw = makeRaw({ menuType: 'C', perms: 'unknown:perm:list' })
expect(buildPath(raw)).toBe('')
})
it('should return empty string when perms is null', () => {
const raw = makeRaw({ menuType: 'C', perms: null })
expect(buildPath(raw)).toBe('')
})
})
describe('inferIcon', () => {
it('should return user icon for system:user:list', () => {
const raw = makeRaw({ perms: 'system:user:list' })
expect(inferIcon(raw)).toBe('user')
})
it('should return role icon for system:role:list', () => {
const raw = makeRaw({ perms: 'system:role:list' })
expect(inferIcon(raw)).toBe('role')
})
it('should return loginlog icon for audit:login:list', () => {
const raw = makeRaw({ perms: 'audit:login:list' })
expect(inferIcon(raw)).toBe('loginlog')
})
it('should return empty string for unknown permission', () => {
const raw = makeRaw({ perms: 'unknown:perm:list' })
expect(inferIcon(raw)).toBe('')
})
it('should return empty string when perms is null', () => {
const raw = makeRaw({ perms: null })
expect(inferIcon(raw)).toBe('')
})
})
describe('menuTypeMap', () => {
it('should map M to directory', () => {
expect(menuTypeMap['M']).toBe('directory')
})
it('should map C to menu', () => {
expect(menuTypeMap['C']).toBe('menu')
})
it('should map F to button', () => {
expect(menuTypeMap['F']).toBe('button')
})
})
})
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Notice } from '@/api/loginLog'
import { NoticeStatus } from '@/constants/status'
vi.mock('@/utils/request', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
import request from '@/utils/request'
const mockNotices: Notice[] = [
{
id: 1,
noticeTitle: '系统维护通知',
noticeContent: '系统将于今晚维护',
noticeType: '1',
status: NoticeStatus.ACTIVE,
createBy: 'admin',
createdAt: '2026-01-01T00:00:00',
updatedAt: '2026-01-01T00:00:00',
},
{
id: 2,
noticeTitle: '版本更新公告',
noticeContent: '新版本已发布',
noticeType: '2',
status: NoticeStatus.ACTIVE,
createBy: 'admin',
createdAt: '2026-01-02T00:00:00',
updatedAt: '2026-01-02T00:00:00',
},
{
id: 3,
noticeTitle: '测试通知',
noticeContent: '这是一条草稿通知',
noticeType: '1',
status: NoticeStatus.INACTIVE,
createBy: 'user1',
createdAt: '2026-01-03T00:00:00',
updatedAt: '2026-01-03T00:00:00',
},
]
describe('noticeApi.getPage (client-side pagination)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return paginated results without filters', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockNotices)
const { noticeApi } = await import('@/api/loginLog')
const result = await noticeApi.getPage({ page: 0, size: 2 })
expect(result.content).toHaveLength(2)
expect(result.totalElements).toBe(3)
expect(result.totalPages).toBe(2)
expect(result.first).toBe(true)
expect(result.last).toBe(false)
expect(result.number).toBe(0)
expect(result.size).toBe(2)
})
it('should return second page correctly', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockNotices)
const { noticeApi } = await import('@/api/loginLog')
const result = await noticeApi.getPage({ page: 1, size: 2 })
expect(result.content).toHaveLength(1)
expect(result.content[0].id).toBe(3)
expect(result.first).toBe(false)
expect(result.last).toBe(true)
})
it('should filter by title', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockNotices)
const { noticeApi } = await import('@/api/loginLog')
const result = await noticeApi.getPage({ page: 0, size: 10, title: '维护' })
expect(result.content).toHaveLength(1)
expect(result.content[0].noticeTitle).toBe('系统维护通知')
})
it('should filter by type', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockNotices)
const { noticeApi } = await import('@/api/loginLog')
const result = await noticeApi.getPage({ page: 0, size: 10, type: '2' })
expect(result.content).toHaveLength(1)
expect(result.content[0].noticeType).toBe('2')
})
it('should filter by status', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockNotices)
const { noticeApi } = await import('@/api/loginLog')
const result = await noticeApi.getPage({ page: 0, size: 10, status: String(NoticeStatus.INACTIVE) })
expect(result.content).toHaveLength(1)
expect(result.content[0].status).toBe(NoticeStatus.INACTIVE)
})
it('should return empty page when page exceeds data', async () => {
vi.mocked(request.get).mockResolvedValueOnce(mockNotices)
const { noticeApi } = await import('@/api/loginLog')
const result = await noticeApi.getPage({ page: 10, size: 2 })
expect(result.content).toHaveLength(0)
expect(result.totalElements).toBe(3)
})
it('should handle empty data', async () => {
vi.mocked(request.get).mockResolvedValueOnce([])
const { noticeApi } = await import('@/api/loginLog')
const result = await noticeApi.getPage({ page: 0, size: 10 })
expect(result.content).toHaveLength(0)
expect(result.totalElements).toBe(0)
expect(result.totalPages).toBe(0)
expect(result.first).toBe(true)
expect(result.last).toBe(true)
})
})
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/utils/request', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}))
import request from '@/utils/request'
import { roleApi } from '@/api/role.api'
describe('roleApi', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('create', () => {
it('should send POST /roles with role data', async () => {
const mockRole = { id: 1, roleName: '测试角色', roleKey: 'test_role', roleSort: 1, status: 1, permissions: [], createdAt: '', updatedAt: '' }
vi.mocked(request.post).mockResolvedValue(mockRole)
const data = { roleName: '测试角色', roleKey: 'test_role', roleSort: 1, permissions: [] }
const result = await roleApi.create(data)
expect(request.post).toHaveBeenCalledWith('/roles', data)
expect(result).toEqual(mockRole)
})
it('should send roleSort >= 1 for valid role creation', async () => {
const mockRole = { id: 2, roleName: '角色2', roleKey: 'role_2', roleSort: 1, status: 1, permissions: [], createdAt: '', updatedAt: '' }
vi.mocked(request.post).mockResolvedValue(mockRole)
const data = { roleName: '角色2', roleKey: 'role_2', roleSort: 1, permissions: [] }
await roleApi.create(data)
expect(request.post).toHaveBeenCalledWith('/roles', expect.objectContaining({ roleSort: 1 }))
})
})
describe('getPage', () => {
it('should send GET /roles/page with pagination params', async () => {
const mockResponse = { content: [], totalElements: 0, totalPages: 0, size: 10, number: 0 }
vi.mocked(request.get).mockResolvedValue(mockResponse)
const params = { page: 0, size: 10 }
const result = await roleApi.getPage(params)
expect(request.get).toHaveBeenCalledWith('/roles/page', { params })
expect(result).toEqual(mockResponse)
})
})
describe('update', () => {
it('should send PUT /roles/:id with update data', async () => {
const mockRole = { id: 1, roleName: '更新角色', roleKey: 'updated', roleSort: 2, status: 1, permissions: [], createdAt: '', updatedAt: '' }
vi.mocked(request.put).mockResolvedValue(mockRole)
const data = { roleName: '更新角色', roleSort: 2 }
const result = await roleApi.update(1, data)
expect(request.put).toHaveBeenCalledWith('/roles/1', data)
expect(result).toEqual(mockRole)
})
})
describe('delete', () => {
it('should send DELETE /roles/:id', async () => {
vi.mocked(request.delete).mockResolvedValue(undefined)
await roleApi.delete(1)
expect(request.delete).toHaveBeenCalledWith('/roles/1')
})
})
describe('getAllPermissions', () => {
it('should send GET /permissions', async () => {
const mockPermissions = [
{ id: 1, name: '用户查看', code: 'system:user:list', resource: 'user', action: 'list' },
{ id: 2, name: '角色查看', code: 'system:role:list', resource: 'role', action: 'list' },
]
vi.mocked(request.get).mockResolvedValue(mockPermissions)
const result = await roleApi.getAllPermissions()
expect(request.get).toHaveBeenCalledWith('/permissions')
expect(result).toEqual(mockPermissions)
})
})
describe('assignPermissions', () => {
it('should send POST /roles/:id/permissions with permission IDs', async () => {
vi.mocked(request.post).mockResolvedValue(undefined)
await roleApi.assignPermissions(1, [1, 2, 3])
expect(request.post).toHaveBeenCalledWith('/roles/1/permissions', { permissionIds: [1, 2, 3] })
})
})
describe('updateStatus', () => {
it('should send PUT /roles/:id/status with status', async () => {
vi.mocked(request.put).mockResolvedValue(undefined)
await roleApi.updateStatus(1, 'INACTIVE')
expect(request.put).toHaveBeenCalledWith('/roles/1/status', { status: 'INACTIVE' })
})
})
})
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import AuthGuard from '@/components/AuthGuard'
vi.mock('@/stores/useAuthStore', () => ({
useAuthStore: vi.fn(),
}))
import { useAuthStore } from '@/stores/useAuthStore'
const mockUseAuthStore = vi.mocked(useAuthStore)
function renderWithRouter(ui: React.ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>)
}
describe('AuthGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render children when authenticated', () => {
mockUseAuthStore.mockImplementation((selector: any) =>
selector({ isAuthenticated: true })
)
renderWithRouter(
<AuthGuard>
<div>Protected Page</div>
</AuthGuard>
)
expect(screen.getByText('Protected Page')).toBeInTheDocument()
})
it('should redirect to /login when not authenticated', () => {
mockUseAuthStore.mockImplementation((selector: any) =>
selector({ isAuthenticated: false })
)
renderWithRouter(
<AuthGuard>
<div>Protected Page</div>
</AuthGuard>
)
expect(screen.queryByText('Protected Page')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import ChartContainer from '@/components/ChartContainer'
describe('ChartContainer', () => {
it('should call onInit with container element on mount', () => {
const onInit = vi.fn()
render(<ChartContainer onInit={onInit} />)
expect(onInit).toHaveBeenCalledTimes(1)
expect(onInit).toHaveBeenCalledWith(expect.any(HTMLElement))
})
it('should call onDestroy on unmount', () => {
const onDestroy = vi.fn()
const onInit = vi.fn()
const { unmount } = render(<ChartContainer onInit={onInit} onDestroy={onDestroy} />)
expect(onDestroy).not.toHaveBeenCalled()
unmount()
expect(onDestroy).toHaveBeenCalledTimes(1)
})
it('should not crash when onDestroy is not provided', () => {
const onInit = vi.fn()
const { unmount } = render(<ChartContainer onInit={onInit} />)
expect(() => unmount()).not.toThrow()
})
it('should apply custom style', () => {
const onInit = vi.fn()
const { container } = render(
<ChartContainer onInit={onInit} style={{ backgroundColor: 'red' }} />
)
const div = container.firstChild as HTMLElement
expect(div.style.backgroundColor).toBe('red')
})
it('should have default width and height 100%', () => {
const onInit = vi.fn()
const { container } = render(<ChartContainer onInit={onInit} />)
const div = container.firstChild as HTMLElement
expect(div.style.width).toBe('100%')
expect(div.style.height).toBe('100%')
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import PermissionGuard from '@/components/PermissionGuard'
vi.mock('@/stores/usePermissionStore', () => ({
usePermissionStore: vi.fn(),
}))
import { usePermissionStore } from '@/stores/usePermissionStore'
const mockUsePermissionStore = vi.mocked(usePermissionStore)
describe('PermissionGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render children when user has permission', () => {
mockUsePermissionStore.mockImplementation((selector: any) =>
selector({ hasPermission: (p: string) => p === 'system:user:list', hasRole: () => false })
)
render(
<PermissionGuard permission="system:user:list">
<div>Protected Content</div>
</PermissionGuard>
)
expect(screen.getByText('Protected Content')).toBeInTheDocument()
})
it('should render fallback when user lacks permission', () => {
mockUsePermissionStore.mockImplementation((selector: any) =>
selector({ hasPermission: () => false, hasRole: () => false })
)
render(
<PermissionGuard permission="system:user:list" fallback={<div>No Access</div>}>
<div>Protected Content</div>
</PermissionGuard>
)
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
expect(screen.getByText('No Access')).toBeInTheDocument()
})
it('should render null fallback by default when no permission', () => {
mockUsePermissionStore.mockImplementation((selector: any) =>
selector({ hasPermission: () => false, hasRole: () => false })
)
const { container } = render(
<PermissionGuard permission="system:user:list">
<div>Protected Content</div>
</PermissionGuard>
)
expect(container.innerHTML).toBe('')
})
it('should check role when type is role', () => {
mockUsePermissionStore.mockImplementation((selector: any) =>
selector({ hasPermission: () => false, hasRole: (r: string) => r === 'admin' })
)
render(
<PermissionGuard role="admin" type="role">
<div>Admin Content</div>
</PermissionGuard>
)
expect(screen.getByText('Admin Content')).toBeInTheDocument()
})
it('should render children when no permission or role specified', () => {
mockUsePermissionStore.mockImplementation((selector: any) =>
selector({ hasPermission: () => false, hasRole: () => false })
)
render(
<PermissionGuard>
<div>Always Visible</div>
</PermissionGuard>
)
expect(screen.getByText('Always Visible')).toBeInTheDocument()
})
})
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAntV } from '@/hooks/useAntV'
class MockChart {
container: HTMLElement
options: any
destroyed = false
constructor(container: HTMLElement, options?: any) {
this.container = container
this.options = options
}
changeData(data: any[]) {
this.options = { ...this.options, data }
}
destroy() {
this.destroyed = true
}
}
describe('useAntV', () => {
it('should initialize chart via initChart', () => {
const { result } = renderHook(() => useAntV(MockChart, { theme: 'dark' }))
const container = document.createElement('div')
act(() => {
result.current.initChart(container)
})
expect(result.current.chartRef.current).toBeInstanceOf(MockChart)
})
it('should destroy previous chart when re-initializing', () => {
const { result } = renderHook(() => useAntV(MockChart))
const container1 = document.createElement('div')
const container2 = document.createElement('div')
act(() => {
result.current.initChart(container1)
})
const firstChart = result.current.chartRef.current as MockChart
act(() => {
result.current.initChart(container2)
})
expect(firstChart.destroyed).toBe(true)
expect(result.current.chartRef.current).not.toBe(firstChart)
})
it('should update data via updateData', () => {
const { result } = renderHook(() => useAntV(MockChart))
const container = document.createElement('div')
act(() => {
result.current.initChart(container)
})
act(() => {
result.current.updateData([{ x: 1, y: 2 }])
})
const chart = result.current.chartRef.current as MockChart
expect(chart.options.data).toEqual([{ x: 1, y: 2 }])
})
it('should auto-destroy chart on unmount', () => {
const { result, unmount } = renderHook(() => useAntV(MockChart))
const container = document.createElement('div')
act(() => {
result.current.initChart(container)
})
const chart = result.current.chartRef.current as MockChart
unmount()
expect(chart.destroyed).toBe(true)
})
it('should not auto-destroy when autoDestroy is false', () => {
const { result, unmount } = renderHook(() =>
useAntV(MockChart, undefined, { autoDestroy: false })
)
const container = document.createElement('div')
act(() => {
result.current.initChart(container)
})
const chart = result.current.chartRef.current as MockChart
unmount()
expect(chart.destroyed).toBe(false)
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook } from '@testing-library/react'
vi.mock('@/stores/usePermissionStore', () => ({
usePermissionStore: vi.fn(),
}))
import { usePermissionStore } from '@/stores/usePermissionStore'
import { usePermission } from '@/hooks/usePermission'
const mockUsePermissionStore = vi.mocked(usePermissionStore)
describe('usePermission', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return hasPermission, hasRole, permissions, roles', () => {
const mockState = {
hasPermission: (p: string) => p === 'system:user:list',
hasRole: (r: string) => r === 'admin',
permissions: ['system:user:list', 'system:user:add'],
roles: ['admin'],
}
mockUsePermissionStore.mockImplementation((selector: any) => selector(mockState))
const { result } = renderHook(() => usePermission())
expect(result.current.hasPermission('system:user:list')).toBe(true)
expect(result.current.hasPermission('system:role:list')).toBe(false)
expect(result.current.hasRole('admin')).toBe(true)
expect(result.current.hasRole('user')).toBe(false)
expect(result.current.permissions).toEqual(['system:user:list', 'system:user:add'])
expect(result.current.roles).toEqual(['admin'])
})
it('should return empty arrays when no permissions', () => {
const mockState = {
hasPermission: () => false,
hasRole: () => false,
permissions: [],
roles: [],
}
mockUsePermissionStore.mockImplementation((selector: any) => selector(mockState))
const { result } = renderHook(() => usePermission())
expect(result.current.permissions).toEqual([])
expect(result.current.roles).toEqual([])
})
})
@@ -0,0 +1,216 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { redirect } from 'react-router'
const mockInitFromStorage = vi.fn()
const mockLogout = vi.fn()
const mockFetchUserMenus = vi.fn(() => Promise.resolve())
const mockInitFromStoragePerm = vi.fn(() => false)
let mockAuthState: any = {
initialized: false,
isAuthenticated: false,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
let mockPermState: any = {
loaded: false,
initFromStorage: mockInitFromStoragePerm,
fetchUserMenus: mockFetchUserMenus,
}
vi.mock('@/stores/useAuthStore', () => ({
useAuthStore: {
getState: vi.fn(() => mockAuthState),
},
}))
vi.mock('@/stores/usePermissionStore', () => ({
usePermissionStore: {
getState: vi.fn(() => mockPermState),
},
}))
import { authLoader } from '@/router/guards'
function isRedirectToLogin(result: any) {
if (result === null) return false
return result.status === 302 && result.headers.get('Location') === '/login'
}
describe('authLoader', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
mockAuthState = {
initialized: false,
isAuthenticated: false,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
mockPermState = {
loaded: false,
initFromStorage: mockInitFromStoragePerm,
fetchUserMenus: mockFetchUserMenus,
}
})
it('should redirect to /login when no token in localStorage', async () => {
const result = await authLoader()
expect(isRedirectToLogin(result)).toBe(true)
})
it('should redirect to /login when initFromStorage sets isAuthenticated to false', async () => {
localStorage.setItem('token', 'valid-token')
mockAuthState = {
initialized: false,
isAuthenticated: false,
initFromStorage: mockInitFromStorage.mockImplementation(() => {
mockAuthState = {
initialized: true,
isAuthenticated: false,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
}),
logout: mockLogout,
}
const result = await authLoader()
expect(isRedirectToLogin(result)).toBe(true)
})
it('should return null when authenticated after initFromStorage', async () => {
localStorage.setItem('token', 'valid-token')
let callCount = 0
mockInitFromStorage.mockImplementation(() => {
mockAuthState = {
initialized: true,
isAuthenticated: true,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
})
mockAuthState = {
initialized: false,
isAuthenticated: false,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
mockPermState = {
loaded: true,
initFromStorage: mockInitFromStoragePerm,
fetchUserMenus: mockFetchUserMenus,
}
const result = await authLoader()
expect(result).toBeNull()
})
it('should call initFromStorage when not initialized', async () => {
localStorage.setItem('token', 'valid-token')
mockInitFromStorage.mockImplementation(() => {
mockAuthState = {
initialized: true,
isAuthenticated: true,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
})
mockAuthState = {
initialized: false,
isAuthenticated: false,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
mockPermState = {
loaded: true,
initFromStorage: mockInitFromStoragePerm,
fetchUserMenus: mockFetchUserMenus,
}
await authLoader()
expect(mockInitFromStorage).toHaveBeenCalled()
})
it('should not call initFromStorage when already initialized', async () => {
localStorage.setItem('token', 'valid-token')
mockAuthState = {
initialized: true,
isAuthenticated: true,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
mockPermState = {
loaded: true,
initFromStorage: mockInitFromStoragePerm,
fetchUserMenus: mockFetchUserMenus,
}
await authLoader()
expect(mockInitFromStorage).not.toHaveBeenCalled()
})
it('should logout and redirect when fetchUserMenus fails', async () => {
localStorage.setItem('token', 'valid-token')
mockAuthState = {
initialized: true,
isAuthenticated: true,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
mockPermState = {
loaded: false,
initFromStorage: mockInitFromStoragePerm.mockReturnValue(false),
fetchUserMenus: mockFetchUserMenus.mockRejectedValue(new Error('Network error')),
}
const result = await authLoader()
expect(mockLogout).toHaveBeenCalled()
expect(isRedirectToLogin(result)).toBe(true)
})
it('should re-fetch auth state after initFromStorage to get latest isAuthenticated', async () => {
localStorage.setItem('token', 'valid-token')
let getStateCallCount = 0
const { useAuthStore } = await import('@/stores/useAuthStore')
mockInitFromStorage.mockImplementation(() => {
mockAuthState = {
initialized: true,
isAuthenticated: true,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
})
mockAuthState = {
initialized: false,
isAuthenticated: false,
initFromStorage: mockInitFromStorage,
logout: mockLogout,
}
mockPermState = {
loaded: true,
initFromStorage: mockInitFromStoragePerm,
fetchUserMenus: mockFetchUserMenus,
}
const result = await authLoader()
expect(result).toBeNull()
expect(mockInitFromStorage).toHaveBeenCalled()
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest'
import { router } from '@/router'
describe('router configuration', () => {
const routePaths = [
'dashboard',
'users',
'roles',
'menus',
'sys/dept',
'sys/config',
'dict',
'files',
'notice',
'loginlog',
'oplog',
'exceptionlog',
'monitor/online',
'monitor/job',
'monitor/data',
'monitor/server',
'monitor/cache',
]
it('should have login route', () => {
const loginRoute = router.routes.find((r) => r.path === '/login')
expect(loginRoute).toBeDefined()
})
it('should have 403 route', () => {
const forbiddenRoute = router.routes.find((r) => r.path === '/403')
expect(forbiddenRoute).toBeDefined()
})
it('should have root layout route with children', () => {
const rootRoute = router.routes.find((r) => r.path === '/')
expect(rootRoute).toBeDefined()
expect(rootRoute?.children).toBeDefined()
expect(rootRoute?.children?.length).toBeGreaterThan(0)
})
it('should have all expected route paths defined', () => {
const rootRoute = router.routes.find((r) => r.path === '/')
const childPaths = rootRoute?.children?.map((c) => c.path) || []
for (const path of routePaths) {
expect(childPaths).toContain(path)
}
})
it('should have index redirect to dashboard', () => {
const rootRoute = router.routes.find((r) => r.path === '/')
const indexRoute = rootRoute?.children?.find((c) => c.index === true)
expect(indexRoute).toBeDefined()
})
it('should have dept management route', () => {
const rootRoute = router.routes.find((r) => r.path === '/')
const deptRoute = rootRoute?.children?.find((c) => c.path === 'sys/dept')
expect(deptRoute).toBeDefined()
expect(deptRoute?.element).toBeDefined()
})
it('should have all monitor routes', () => {
const rootRoute = router.routes.find((r) => r.path === '/')
const childPaths = rootRoute?.children?.map((c) => c.path) || []
const monitorPaths = [
'monitor/online',
'monitor/job',
'monitor/data',
'monitor/server',
'monitor/cache',
]
for (const path of monitorPaths) {
expect(childPaths).toContain(path)
}
})
})
+17
View File
@@ -0,0 +1,17 @@
import '@testing-library/jest-dom/vitest'
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (index: number) => Object.keys(store)[index] ?? null,
}
})()
if (!globalThis.localStorage || typeof globalThis.localStorage.clear !== 'function') {
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
}
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockFetchUserMenus = vi.fn(() => Promise.resolve())
const mockClearPermissionData = vi.fn()
vi.mock('@/api/auth.api', () => ({
authApi: {
login: vi.fn(() => Promise.resolve({
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.test',
})),
},
}))
vi.mock('@/stores/usePermissionStore', () => ({
usePermissionStore: {
getState: vi.fn(() => ({
fetchUserMenus: mockFetchUserMenus,
clearPermissionData: mockClearPermissionData,
})),
},
}))
import { useAuthStore } from '@/stores/useAuthStore'
describe('useAuthStore', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
useAuthStore.setState({
token: null, userId: null, username: null, nickname: null,
roles: [], permissions: [], isAuthenticated: false, initialized: false,
})
})
describe('login', () => {
it('should set auth state on successful login', async () => {
await useAuthStore.getState().login('admin', 'password')
const state = useAuthStore.getState()
expect(state.isAuthenticated).toBe(true)
expect(state.username).toBe('admin')
expect(state.roles).toEqual(['admin'])
expect(state.token).toBeTruthy()
})
it('should persist token and user info to localStorage', async () => {
await useAuthStore.getState().login('admin', 'password')
expect(localStorage.getItem('token')).toBeTruthy()
expect(localStorage.getItem('username')).toBe('admin')
expect(localStorage.getItem('roles')).toBe(JSON.stringify(['admin']))
})
it('should call fetchUserMenus after login', async () => {
await useAuthStore.getState().login('admin', 'password')
expect(mockFetchUserMenus).toHaveBeenCalled()
})
})
describe('logout', () => {
it('should clear auth state', () => {
useAuthStore.setState({ token: 'test', isAuthenticated: true, username: 'admin', roles: ['admin'] })
useAuthStore.getState().logout()
const state = useAuthStore.getState()
expect(state.token).toBeNull()
expect(state.isAuthenticated).toBe(false)
expect(state.username).toBeNull()
expect(state.roles).toEqual([])
})
it('should clear localStorage', () => {
localStorage.setItem('token', 'test')
localStorage.setItem('username', 'admin')
localStorage.setItem('roles', '["admin"]')
localStorage.setItem('permission', '{}')
useAuthStore.getState().logout()
expect(localStorage.getItem('token')).toBeNull()
expect(localStorage.getItem('username')).toBeNull()
expect(localStorage.getItem('roles')).toBeNull()
expect(localStorage.getItem('permission')).toBeNull()
})
})
describe('initFromStorage', () => {
it('should restore state from valid token', () => {
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo5OTk5OTk5OTk5fQ.test'
localStorage.setItem('token', token)
useAuthStore.getState().initFromStorage()
const state = useAuthStore.getState()
expect(state.isAuthenticated).toBe(true)
expect(state.username).toBe('admin')
})
it('should do nothing when no token in localStorage', () => {
useAuthStore.getState().initFromStorage()
const state = useAuthStore.getState()
expect(state.isAuthenticated).toBe(false)
})
it('should logout when token is expired', () => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGVzIjpbImFkbWluIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAwMDAxfQ.test'
localStorage.setItem('token', expiredToken)
useAuthStore.getState().initFromStorage()
const state = useAuthStore.getState()
expect(state.isAuthenticated).toBe(false)
})
})
describe('setInitialized', () => {
it('should set initialized flag', () => {
useAuthStore.getState().setInitialized(true)
expect(useAuthStore.getState().initialized).toBe(true)
})
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockMenus = [
{
id: 1, name: '系统管理', path: '/system', icon: 'setting', component: '',
parentId: 0, sort: 1, type: 'directory' as const, permission: '', status: 1,
visible: true, children: [
{
id: 2, name: '用户管理', path: '/users', icon: 'user', component: 'system/user',
parentId: 1, sort: 1, type: 'menu' as const, permission: 'system:user:list',
status: 1, visible: true, children: [
{
id: 3, name: '用户新增', path: '', icon: '', component: '',
parentId: 2, sort: 1, type: 'button' as const, permission: 'system:user:add',
status: 1, visible: true,
},
],
},
],
},
]
vi.mock('@/api/menu', () => ({
menuApi: {
getTree: vi.fn(() => Promise.resolve(mockMenus)),
},
}))
import { usePermissionStore } from '@/stores/usePermissionStore'
describe('usePermissionStore', () => {
beforeEach(() => {
localStorage.clear()
usePermissionStore.setState({ roles: [], permissions: [], menus: [], loaded: false })
})
describe('fetchUserMenus', () => {
it('should fetch menus and extract permissions', async () => {
localStorage.setItem('roles', JSON.stringify(['admin']))
await usePermissionStore.getState().fetchUserMenus()
const state = usePermissionStore.getState()
expect(state.menus.length).toBeGreaterThan(0)
expect(state.permissions).toContain('system:user:list')
expect(state.permissions).toContain('system:user:add')
expect(state.roles).toEqual(['admin'])
expect(state.loaded).toBe(true)
})
it('should persist permissions to localStorage', async () => {
localStorage.setItem('roles', JSON.stringify(['admin']))
await usePermissionStore.getState().fetchUserMenus()
const stored = localStorage.getItem('permission')
expect(stored).toBeTruthy()
const parsed = JSON.parse(stored!)
expect(parsed.permissions).toContain('system:user:list')
})
})
describe('hasPermission', () => {
it('should return true when user has the permission', () => {
usePermissionStore.setState({ permissions: ['system:user:list', 'system:user:add'] })
expect(usePermissionStore.getState().hasPermission('system:user:list')).toBe(true)
})
it('should return false when user lacks the permission', () => {
usePermissionStore.setState({ permissions: ['system:role:list'] })
expect(usePermissionStore.getState().hasPermission('system:user:list')).toBe(false)
})
it('should return true when user has wildcard permission', () => {
usePermissionStore.setState({ permissions: ['*'] })
expect(usePermissionStore.getState().hasPermission('system:user:list')).toBe(true)
})
})
describe('hasRole', () => {
it('should return true when user has the role', () => {
usePermissionStore.setState({ roles: ['admin', 'editor'] })
expect(usePermissionStore.getState().hasRole('admin')).toBe(true)
})
it('should return false when user lacks the role', () => {
usePermissionStore.setState({ roles: ['editor'] })
expect(usePermissionStore.getState().hasRole('admin')).toBe(false)
})
it('should return true when user has admin role (super admin)', () => {
usePermissionStore.setState({ roles: ['admin'] })
expect(usePermissionStore.getState().hasRole('any-role')).toBe(true)
})
})
describe('clearPermissionData', () => {
it('should reset all permission state', () => {
usePermissionStore.setState({ roles: ['admin'], permissions: ['system:user:list'], loaded: true })
usePermissionStore.getState().clearPermissionData()
const state = usePermissionStore.getState()
expect(state.roles).toEqual([])
expect(state.permissions).toEqual([])
expect(state.loaded).toBe(false)
})
it('should clear localStorage permission data', () => {
localStorage.setItem('permission', JSON.stringify({ permissions: ['test'] }))
usePermissionStore.getState().clearPermissionData()
expect(localStorage.getItem('permission')).toBeNull()
})
})
describe('initFromStorage', () => {
it('should restore state from localStorage', () => {
localStorage.setItem('permission', JSON.stringify({ permissions: ['system:user:list'], menus: [] }))
localStorage.setItem('roles', JSON.stringify(['admin']))
const result = usePermissionStore.getState().initFromStorage()
expect(result).toBe(true)
expect(usePermissionStore.getState().permissions).toContain('system:user:list')
expect(usePermissionStore.getState().loaded).toBe(true)
})
it('should return false when no data in localStorage', () => {
const result = usePermissionStore.getState().initFromStorage()
expect(result).toBe(false)
})
it('should return false when localStorage data is invalid', () => {
localStorage.setItem('permission', 'invalid-json')
const result = usePermissionStore.getState().initFromStorage()
expect(result).toBe(false)
})
})
})
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { formatDateTime, formatDate, formatTime } from '@/utils/dateFormat'
describe('dateFormat', () => {
describe('formatDateTime', () => {
it('should format ISO date string to yyyy-MM-dd HH:mm:ss', () => {
const result = formatDateTime('2026-05-03T14:30:00')
expect(result).toBe('2026-05-03 14:30:00')
})
it('should format Date object', () => {
const date = new Date(2026, 4, 3, 14, 30, 0)
const result = formatDateTime(date)
expect(result).toContain('2026')
expect(result).toContain('14:30:00')
})
it('should return "-" for null', () => {
expect(formatDateTime(null)).toBe('-')
})
it('should return "-" for undefined', () => {
expect(formatDateTime(undefined)).toBe('-')
})
it('should return "-" for empty string', () => {
expect(formatDateTime('')).toBe('-')
})
})
describe('formatDate', () => {
it('should format ISO date string to yyyy-MM-dd', () => {
const result = formatDate('2026-05-03T14:30:00')
expect(result).toBe('2026-05-03')
})
it('should return "-" for null', () => {
expect(formatDate(null)).toBe('-')
})
})
describe('formatTime', () => {
it('should format ISO date string to HH:mm:ss', () => {
const result = formatTime('2026-05-03T14:30:00')
expect(result).toBe('14:30:00')
})
it('should return "-" for undefined', () => {
expect(formatTime(undefined)).toBe('-')
})
})
})
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ApiErrorHandler, handleApiError } from '@/utils/errorHandler'
import type { ApiError } from '@/utils/errorHandler'
vi.mock('antd', () => ({
message: { error: vi.fn() },
}))
import { message } from 'antd'
function makeError(status: number, data?: Partial<ApiError>): any {
return { response: { status, data: { message: 'Error', ...data } } }
}
describe('errorHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should handle network error (no response)', () => {
ApiErrorHandler.handle(new Error('Network Error'))
expect(message.error).toHaveBeenCalledWith('网络连接失败,请检查网络设置')
})
it('should handle 400 Bad Request', () => {
ApiErrorHandler.handle(makeError(400, { message: '参数错误' }))
expect(message.error).toHaveBeenCalledWith('参数错误')
})
it('should handle 401 Unauthorized', () => {
Object.defineProperty(window, 'location', { value: { href: '', pathname: '/dashboard' }, writable: true })
ApiErrorHandler.handle(makeError(401))
expect(message.error).toHaveBeenCalledWith('登录已过期,请重新登录')
expect(localStorage.getItem('token')).toBeNull()
})
it('should handle 403 Forbidden', () => {
ApiErrorHandler.handle(makeError(403))
expect(message.error).toHaveBeenCalledWith('没有权限访问该资源')
})
it('should handle 404 Not Found', () => {
ApiErrorHandler.handle(makeError(404, { message: '资源不存在' }))
expect(message.error).toHaveBeenCalledWith('资源不存在')
})
it('should handle 409 Conflict', () => {
ApiErrorHandler.handle(makeError(409))
expect(message.error).toHaveBeenCalledWith('Error')
})
it('should handle 422 Validation Error with details', () => {
ApiErrorHandler.handle(makeError(422, { details: { name: '名称必填', email: '邮箱格式错误' } }))
expect(message.error).toHaveBeenCalledWith('名称必填、邮箱格式错误')
})
it('should handle 500 Internal Server Error', () => {
ApiErrorHandler.handle(makeError(500))
expect(message.error).toHaveBeenCalledWith('服务器内部错误,请稍后重试')
})
it('should handle 502/503/504 Service Unavailable', () => {
ApiErrorHandler.handle(makeError(503))
expect(message.error).toHaveBeenCalledWith('服务暂时不可用,请稍后重试')
})
it('should handle unknown status code', () => {
ApiErrorHandler.handle(makeError(418, { message: 'I am a teapot' }))
expect(message.error).toHaveBeenCalledWith('I am a teapot')
})
it('handleApiError should delegate to ApiErrorHandler', () => {
handleApiError(makeError(400, { message: 'test' }))
expect(message.error).toHaveBeenCalledWith('test')
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { checkApiPermission, getRequiredPermission } from '@/utils/permission'
describe('permission', () => {
beforeEach(() => {
localStorage.clear()
})
describe('checkApiPermission', () => {
it('should return true when no permission mapping exists', () => {
expect(checkApiPermission('GET', '/unknown-api')).toBe(true)
})
it('should return true when no permission stored in localStorage', () => {
expect(checkApiPermission('GET', '/users')).toBe(true)
})
it('should return true when user has the required permission', () => {
localStorage.setItem('permission', JSON.stringify({ permissions: ['system:user:list'] }))
expect(checkApiPermission('GET', '/users')).toBe(true)
})
it('should return false when user lacks the required permission', () => {
localStorage.setItem('permission', JSON.stringify({ permissions: ['system:role:list'] }))
expect(checkApiPermission('POST', '/users')).toBe(false)
})
it('should return true for GET /menus (public)', () => {
localStorage.setItem('permission', JSON.stringify({ permissions: [] }))
expect(checkApiPermission('GET', '/menus')).toBe(true)
})
it('should handle URL with numeric ID by matching base pattern', () => {
localStorage.setItem('permission', JSON.stringify({ permissions: ['system:user:edit'] }))
expect(checkApiPermission('PUT', '/users/123')).toBe(true)
})
it('should return true when localStorage parse fails', () => {
localStorage.setItem('permission', 'invalid-json')
expect(checkApiPermission('GET', '/users')).toBe(true)
})
})
describe('getRequiredPermission', () => {
it('should return permission for known API', () => {
expect(getRequiredPermission('GET', '/users')).toBe('system:user:list')
})
it('should return null for unknown API', () => {
expect(getRequiredPermission('GET', '/unknown')).toBeNull()
})
it('should return permission for POST /users', () => {
expect(getRequiredPermission('POST', '/users')).toBe('system:user:add')
})
})
})
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/utils/signature', () => ({
generateSignatureHeaders: vi.fn(() => ({
'X-Signature': 'test-sig',
'X-Timestamp': '1234567890',
'X-Nonce': 'test-nonce',
})),
}))
vi.mock('@/utils/permission', () => ({
checkApiPermission: vi.fn(() => true),
}))
import request from '@/utils/request'
import { generateSignatureHeaders } from '@/utils/signature'
import { checkApiPermission } from '@/utils/permission'
describe('request', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('should attach token to Authorization header when token exists', async () => {
localStorage.setItem('token', 'test-jwt-token')
const config = { headers: {} as any, method: 'get', url: '/users' }
const interceptor = (request as any).interceptors.request.handlers[0]
const result = await interceptor.fulfilled(config)
expect(result.headers.Authorization).toBe('Bearer test-jwt-token')
})
it('should not attach Authorization header when no token', async () => {
const config = { headers: {} as any, method: 'get', url: '/users' }
const interceptor = (request as any).interceptors.request.handlers[0]
const result = await interceptor.fulfilled(config)
expect(result.headers.Authorization).toBeUndefined()
})
it('should add signature headers to request', async () => {
const config = { headers: {} as any, method: 'get', url: '/users' }
const interceptor = (request as any).interceptors.request.handlers[0]
const result = await interceptor.fulfilled(config)
expect(result.headers['X-Signature']).toBe('test-sig')
expect(result.headers['X-Timestamp']).toBe('1234567890')
expect(result.headers['X-Nonce']).toBe('test-nonce')
})
it('should call generateSignatureHeaders with correct args', async () => {
const config = { headers: {} as any, method: 'POST', url: '/users', data: { name: 'test' } }
const interceptor = (request as any).interceptors.request.handlers[0]
await interceptor.fulfilled(config)
expect(generateSignatureHeaders).toHaveBeenCalledWith('POST', '/api/users', { name: 'test' })
})
it('should reject when checkApiPermission returns false', async () => {
vi.mocked(checkApiPermission).mockReturnValueOnce(false)
const config = { headers: {} as any, method: 'get', url: '/users' }
const interceptor = (request as any).interceptors.request.handlers[0]
await expect(interceptor.fulfilled(config)).rejects.toThrow('无权限访问此接口')
})
it('should unwrap response.data in response interceptor', () => {
const interceptor = (request as any).interceptors.response.handlers[0]
const response = { data: { id: 1, name: 'test' } }
const result = interceptor.fulfilled(response)
expect(result).toEqual({ id: 1, name: 'test' })
})
it('should clear token and redirect on 401 response', async () => {
localStorage.setItem('token', 'old-token')
const originalLocation = window.location
Object.defineProperty(window, 'location', { value: { href: '', pathname: '/dashboard' }, writable: true })
const interceptor = (request as any).interceptors.response.handlers[0]
const error = { response: { status: 401 } }
await expect(interceptor.rejected(error)).rejects.toEqual(error)
expect(localStorage.getItem('token')).toBeNull()
Object.defineProperty(window, 'location', { value: originalLocation, writable: true })
})
it('should not redirect on 401 if already on login page', async () => {
const originalLocation = window.location
Object.defineProperty(window, 'location', { value: { href: '', pathname: '/login' }, writable: true })
const interceptor = (request as any).interceptors.response.handlers[0]
const error = { response: { status: 401 } }
await expect(interceptor.rejected(error)).rejects.toEqual(error)
expect(window.location.href).toBe('')
Object.defineProperty(window, 'location', { value: originalLocation, writable: true })
})
})
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { generateSignature, generateSignatureHeaders } from '@/utils/signature'
describe('signature', () => {
describe('generateSignature', () => {
it('should generate a consistent signature for the same inputs', () => {
const sig1 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce')
const sig2 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce')
expect(sig1).toBe(sig2)
})
it('should generate different signatures for different methods', () => {
const sigGet = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce')
const sigPost = generateSignature('POST', '/api/users', '', '', 1234567890, 'test-nonce')
expect(sigGet).not.toBe(sigPost)
})
it('should generate different signatures for different paths', () => {
const sig1 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce')
const sig2 = generateSignature('GET', '/api/roles', '', '', 1234567890, 'test-nonce')
expect(sig1).not.toBe(sig2)
})
it('should generate different signatures for different timestamps', () => {
const sig1 = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce')
const sig2 = generateSignature('GET', '/api/users', '', '', 1234567891, 'test-nonce')
expect(sig1).not.toBe(sig2)
})
it('should return a Base64 encoded string', () => {
const sig = generateSignature('GET', '/api/users', '', '', 1234567890, 'test-nonce')
expect(sig).toMatch(/^[A-Za-z0-9+/]+=*$/)
})
})
describe('generateSignatureHeaders', () => {
it('should return headers with X-Signature, X-Timestamp, X-Nonce', () => {
const headers = generateSignatureHeaders('GET', '/api/users')
expect(headers).toHaveProperty('X-Signature')
expect(headers).toHaveProperty('X-Timestamp')
expect(headers).toHaveProperty('X-Nonce')
})
it('should have numeric X-Timestamp', () => {
const headers = generateSignatureHeaders('GET', '/api/users')
expect(Number(headers['X-Timestamp'])).not.toBeNaN()
})
it('should have non-empty X-Nonce', () => {
const headers = generateSignatureHeaders('GET', '/api/users')
expect(headers['X-Nonce'].length).toBeGreaterThan(0)
})
})
describe('URL parsing (via generateSignatureHeaders)', () => {
it('should handle relative URL without query', () => {
const headers = generateSignatureHeaders('GET', '/api/users')
expect(headers['X-Signature']).toBeTruthy()
})
it('should handle relative URL with query', () => {
const headers = generateSignatureHeaders('GET', '/api/users?page=1&size=10')
expect(headers['X-Signature']).toBeTruthy()
})
it('should handle absolute URL', () => {
const headers = generateSignatureHeaders('GET', 'http://localhost:8080/api/users?id=1')
expect(headers['X-Signature']).toBeTruthy()
})
})
})
@@ -13,6 +13,11 @@ import {
AuditOutlined,
FileSearchOutlined,
WarningOutlined,
DesktopOutlined,
ScheduleOutlined,
DatabaseOutlined,
CloudServerOutlined,
CloudOutlined,
} from '@ant-design/icons'
import { usePermissionStore } from '@/stores/usePermissionStore'
import type { MenuItem } from '@/api/menu'
@@ -35,6 +40,11 @@ const iconMap: Record<string, React.ReactNode> = {
loginlog: <AuditOutlined />,
oplog: <FileSearchOutlined />,
exceptionlog: <WarningOutlined />,
'monitor/online': <DesktopOutlined />,
'monitor/job': <ScheduleOutlined />,
'monitor/data': <DatabaseOutlined />,
'monitor/server': <CloudServerOutlined />,
'monitor/cache': <CloudOutlined />,
}
const pathMap: Record<string, string> = {
@@ -49,6 +59,11 @@ const pathMap: Record<string, string> = {
loginlog: '/loginlog',
oplog: '/oplog',
exceptionlog: '/exceptionlog',
'monitor/online': '/monitor/online',
'monitor/job': '/monitor/job',
'monitor/data': '/monitor/data',
'monitor/server': '/monitor/server',
'monitor/cache': '/monitor/cache',
}
function convertMenus(items: MenuItem[]): AntMenuItem[] {
@@ -1,15 +1,88 @@
import { Suspense } from 'react'
import { Outlet } from 'react-router'
import { Suspense, useMemo } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router'
import { ProLayout } from '@ant-design/pro-components'
import { Spin } from 'antd'
import {
DashboardOutlined,
UserOutlined,
TeamOutlined,
MenuOutlined,
SettingOutlined,
BookOutlined,
FileOutlined,
NotificationOutlined,
AuditOutlined,
FileSearchOutlined,
WarningOutlined,
MonitorOutlined,
} from '@ant-design/icons'
import { useAppStore } from '@/stores/useAppStore'
import { useAuthStore } from '@/stores/useAuthStore'
import { usePermissionStore } from '@/stores/usePermissionStore'
import HeaderRight from './HeaderRight'
import type { MenuItem } from '@/api/menu'
const iconMap: Record<string, React.ReactNode> = {
dashboard: <DashboardOutlined />,
user: <UserOutlined />,
users: <UserOutlined />,
role: <TeamOutlined />,
roles: <TeamOutlined />,
menu: <MenuOutlined />,
menus: <MenuOutlined />,
config: <SettingOutlined />,
dict: <BookOutlined />,
file: <FileOutlined />,
files: <FileOutlined />,
notice: <NotificationOutlined />,
loginlog: <AuditOutlined />,
oplog: <FileSearchOutlined />,
exceptionlog: <WarningOutlined />,
monitor: <MonitorOutlined />,
}
interface ProLayoutRoute {
path: string
name: string
icon?: React.ReactNode
routes?: ProLayoutRoute[]
}
function convertToProRoutes(items: MenuItem[]): ProLayoutRoute[] {
return items
.filter((item) => item.type !== 'button' && item.visible !== false)
.map((item) => {
const icon = iconMap[item.icon] || iconMap[item.permission]
const route: ProLayoutRoute = {
path: item.path || `/custom/${item.id}`,
name: item.name,
icon,
}
if (item.children?.length) {
const childRoutes = convertToProRoutes(item.children)
if (childRoutes.length > 0) {
route.routes = childRoutes
}
}
return route
})
}
export default function DefaultLayout() {
const collapsed = useAppStore((s) => s.collapsed)
const toggleCollapsed = useAppStore((s) => s.toggleCollapsed)
const username = useAuthStore((s) => s.username)
const menus = usePermissionStore((s) => s.menus)
const navigate = useNavigate()
const location = useLocation()
const route = useMemo(() => ({
path: '/',
routes: [
{ path: '/dashboard', name: '仪表盘', icon: <DashboardOutlined /> },
...convertToProRoutes(menus),
],
}), [menus])
return (
<ProLayout
@@ -20,7 +93,18 @@ export default function DefaultLayout() {
onCollapse={toggleCollapsed}
fixSiderbar
fixedHeader
menuItemRender={(item, dom) => <a onClick={() => item.onClick?.()}>{dom}</a>}
route={route}
location={{ pathname: location.pathname }}
menuItemRender={(item, dom) => (
<a
onClick={() => {
if (item.path) navigate(item.path)
}}
>
{dom}
</a>
)}
subMenuItemRender={(_item, dom) => <span>{dom}</span>}
headerTitleRender={(logo, title) => (
<a onClick={toggleCollapsed} style={{ cursor: 'pointer' }}>
{logo}{title}
+18
View File
@@ -0,0 +1,18 @@
import { Result, Button } from 'antd'
import { ToolOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router'
export default function CacheMonitor() {
const navigate = useNavigate()
return (
<div style={{ padding: 24 }}>
<Result
icon={<ToolOutlined />}
title="缓存监控"
subTitle="该功能正在开发中,敬请期待"
extra={<Button type="primary" onClick={() => navigate('/dashboard')}></Button>}
/>
</div>
)
}
@@ -0,0 +1,18 @@
import { Result, Button } from 'antd'
import { ToolOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router'
export default function DataMonitor() {
const navigate = useNavigate()
return (
<div style={{ padding: 24 }}>
<Result
icon={<ToolOutlined />}
title="数据监控"
subTitle="该功能正在开发中,敬请期待"
extra={<Button type="primary" onClick={() => navigate('/dashboard')}></Button>}
/>
</div>
)
}
@@ -0,0 +1,18 @@
import { Result, Button } from 'antd'
import { ToolOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router'
export default function ScheduledTasks() {
const navigate = useNavigate()
return (
<div style={{ padding: 24 }}>
<Result
icon={<ToolOutlined />}
title="定时任务"
subTitle="该功能正在开发中,敬请期待"
extra={<Button type="primary" onClick={() => navigate('/dashboard')}></Button>}
/>
</div>
)
}
@@ -0,0 +1,18 @@
import { Result, Button } from 'antd'
import { ToolOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router'
export default function OnlineUsers() {
const navigate = useNavigate()
return (
<div style={{ padding: 24 }}>
<Result
icon={<ToolOutlined />}
title="在线用户"
subTitle="该功能正在开发中,敬请期待"
extra={<Button type="primary" onClick={() => navigate('/dashboard')}></Button>}
/>
</div>
)
}
@@ -0,0 +1,18 @@
import { Result, Button } from 'antd'
import { ToolOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router'
export default function ServerMonitor() {
const navigate = useNavigate()
return (
<div style={{ padding: 24 }}>
<Result
icon={<ToolOutlined />}
title="服务监控"
subTitle="该功能正在开发中,敬请期待"
extra={<Button type="primary" onClick={() => navigate('/dashboard')}></Button>}
/>
</div>
)
}
@@ -0,0 +1,18 @@
import { Result, Button } from 'antd'
import { ToolOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router'
export default function DeptManagement() {
const navigate = useNavigate()
return (
<div style={{ padding: 24 }}>
<Result
icon={<ToolOutlined />}
title="部门管理"
subTitle="该功能正在开发中,敬请期待"
extra={<Button type="primary" onClick={() => navigate('/dashboard')}></Button>}
/>
</div>
)
}
+12
View File
@@ -7,6 +7,7 @@ import {
UserManagement,
RoleManagement,
MenuManagement,
DeptManagement,
ConfigManagement,
DictManagement,
FileManagement,
@@ -14,6 +15,11 @@ import {
LoginLog,
OperationLog,
ExceptionLog,
OnlineUsers,
ScheduledTasks,
DataMonitor,
ServerMonitor,
CacheMonitor,
Forbidden,
} from './routes'
@@ -36,6 +42,7 @@ export const router = createBrowserRouter([
{ path: 'users', element: <UserManagement /> },
{ path: 'roles', element: <RoleManagement /> },
{ path: 'menus', element: <MenuManagement /> },
{ path: 'sys/dept', element: <DeptManagement /> },
{ path: 'sys/config', element: <ConfigManagement /> },
{ path: 'dict', element: <DictManagement /> },
{ path: 'files', element: <FileManagement /> },
@@ -43,6 +50,11 @@ export const router = createBrowserRouter([
{ path: 'loginlog', element: <LoginLog /> },
{ path: 'oplog', element: <OperationLog /> },
{ path: 'exceptionlog', element: <ExceptionLog /> },
{ path: 'monitor/online', element: <OnlineUsers /> },
{ path: 'monitor/job', element: <ScheduledTasks /> },
{ path: 'monitor/data', element: <DataMonitor /> },
{ path: 'monitor/server', element: <ServerMonitor /> },
{ path: 'monitor/cache', element: <CacheMonitor /> },
],
},
])
+7 -1
View File
@@ -5,6 +5,7 @@ const Dashboard = lazy(() => import('@/pages/dashboard'))
const UserManagement = lazy(() => import('@/pages/system/user'))
const RoleManagement = lazy(() => import('@/pages/system/role'))
const MenuManagement = lazy(() => import('@/pages/system/menu'))
const DeptManagement = lazy(() => import('@/pages/system/dept'))
const ConfigManagement = lazy(() => import('@/pages/config/config'))
const DictManagement = lazy(() => import('@/pages/config/dict'))
const FileManagement = lazy(() => import('@/pages/file'))
@@ -12,8 +13,13 @@ const NoticeManagement = lazy(() => import('@/pages/notify'))
const LoginLog = lazy(() => import('@/pages/log/login'))
const OperationLog = lazy(() => import('@/pages/log/op'))
const ExceptionLog = lazy(() => import('@/pages/log/ex'))
const OnlineUsers = lazy(() => import('@/pages/monitor/online'))
const ScheduledTasks = lazy(() => import('@/pages/monitor/job'))
const DataMonitor = lazy(() => import('@/pages/monitor/data'))
const ServerMonitor = lazy(() => import('@/pages/monitor/server'))
const CacheMonitor = lazy(() => import('@/pages/monitor/cache'))
const Forbidden = lazy(() => import('@/pages/403'))
const DefaultLayout = lazy(() => import('@/layouts/DefaultLayout/index'))
export { authLoader } from './guards'
export { DefaultLayout, Login, Dashboard, UserManagement, RoleManagement, MenuManagement, ConfigManagement, DictManagement, FileManagement, NoticeManagement, LoginLog, OperationLog, ExceptionLog, Forbidden }
export { DefaultLayout, Login, Dashboard, UserManagement, RoleManagement, MenuManagement, DeptManagement, ConfigManagement, DictManagement, FileManagement, NoticeManagement, LoginLog, OperationLog, ExceptionLog, OnlineUsers, ScheduledTasks, DataMonitor, ServerMonitor, CacheMonitor, Forbidden }