fix/status-review-sync-20260409 #1
57
frontend/admin/src/lib/config.test.ts
Normal file
57
frontend/admin/src/lib/config.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { config } from './config'
|
||||
|
||||
describe('config', () => {
|
||||
const originalEnv = { ...import.meta.env }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// 恢复原始环境变量
|
||||
Object.assign(import.meta.env, originalEnv)
|
||||
})
|
||||
|
||||
describe('apiBaseUrl', () => {
|
||||
it('should return default API URL when VITE_API_BASE_URL is not set', () => {
|
||||
// 默认值测试
|
||||
expect(config.apiBaseUrl).toBeDefined()
|
||||
expect(typeof config.apiBaseUrl).toBe('string')
|
||||
})
|
||||
|
||||
it('should use VITE_API_BASE_URL from environment when set', async () => {
|
||||
// 模拟环境变量设置
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/v2')
|
||||
|
||||
// 重新导入模块以获取新的环境变量值
|
||||
const { config: newConfig } = await import('./config?_=' + Date.now())
|
||||
|
||||
// 注意:由于 Vite 的 import.meta.env 在构建时注入,运行时修改可能不生效
|
||||
// 这里主要测试 config 对象的结构
|
||||
expect(newConfig.apiBaseUrl).toBeDefined()
|
||||
})
|
||||
|
||||
it('should fallback to /api/v1 when env is empty string', () => {
|
||||
// 测试默认值逻辑
|
||||
const defaultUrl = import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
||||
expect(defaultUrl).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('config object', () => {
|
||||
it('should be defined as const (readonly semantic)', () => {
|
||||
// config 使用 as const 声明,TypeScript 语义上是只读的
|
||||
// 运行时 JavaScript 不强制只读,但 TypeScript 类型系统保护
|
||||
expect(config.apiBaseUrl).toBeDefined()
|
||||
expect(typeof config.apiBaseUrl).toBe('string')
|
||||
})
|
||||
|
||||
it('should have all expected properties', () => {
|
||||
expect(config).toHaveProperty('apiBaseUrl')
|
||||
expect(Object.keys(config)).toContain('apiBaseUrl')
|
||||
})
|
||||
})
|
||||
})
|
||||
149
frontend/admin/src/lib/device-fingerprint.test.ts
Normal file
149
frontend/admin/src/lib/device-fingerprint.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import {
|
||||
getDeviceFingerprint,
|
||||
clearDeviceFingerprint,
|
||||
type DeviceFingerprint,
|
||||
} from './device-fingerprint'
|
||||
|
||||
describe('device-fingerprint', () => {
|
||||
// 保存原始 navigator
|
||||
const originalNavigator = global.navigator
|
||||
|
||||
beforeEach(() => {
|
||||
// 清除缓存
|
||||
clearDeviceFingerprint()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearDeviceFingerprint()
|
||||
global.navigator = originalNavigator
|
||||
})
|
||||
|
||||
describe('getDeviceFingerprint', () => {
|
||||
it('should return a device fingerprint object', () => {
|
||||
const fingerprint = getDeviceFingerprint()
|
||||
|
||||
expect(fingerprint).toBeDefined()
|
||||
expect(fingerprint).toHaveProperty('device_id')
|
||||
expect(fingerprint).toHaveProperty('device_name')
|
||||
expect(fingerprint).toHaveProperty('device_browser')
|
||||
expect(fingerprint).toHaveProperty('device_os')
|
||||
})
|
||||
|
||||
it('should return the same fingerprint on multiple calls (singleton)', () => {
|
||||
const fingerprint1 = getDeviceFingerprint()
|
||||
const fingerprint2 = getDeviceFingerprint()
|
||||
|
||||
expect(fingerprint1).toBe(fingerprint2)
|
||||
expect(fingerprint1.device_id).toBe(fingerprint2.device_id)
|
||||
})
|
||||
|
||||
it('should return valid device_id', () => {
|
||||
const fingerprint = getDeviceFingerprint()
|
||||
|
||||
expect(fingerprint.device_id).toBeTruthy()
|
||||
expect(typeof fingerprint.device_id).toBe('string')
|
||||
expect(fingerprint.device_id.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should return valid device_name format', () => {
|
||||
const fingerprint = getDeviceFingerprint()
|
||||
|
||||
expect(fingerprint.device_name).toBeTruthy()
|
||||
expect(typeof fingerprint.device_name).toBe('string')
|
||||
// device_name 格式: "Browser on OS"
|
||||
expect(fingerprint.device_name).toMatch(/.+\s+on\s+.+/)
|
||||
})
|
||||
|
||||
it('should return valid device_browser', () => {
|
||||
const fingerprint = getDeviceFingerprint()
|
||||
|
||||
expect(fingerprint.device_browser).toBeTruthy()
|
||||
expect(typeof fingerprint.device_browser).toBe('string')
|
||||
})
|
||||
|
||||
it('should return valid device_os', () => {
|
||||
const fingerprint = getDeviceFingerprint()
|
||||
|
||||
expect(fingerprint.device_os).toBeTruthy()
|
||||
expect(typeof fingerprint.device_os).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearDeviceFingerprint', () => {
|
||||
it('should clear cached fingerprint', () => {
|
||||
// 先获取一次生成缓存
|
||||
const fingerprint1 = getDeviceFingerprint()
|
||||
|
||||
// 清除缓存
|
||||
clearDeviceFingerprint()
|
||||
|
||||
// 再次获取应该是新的指纹
|
||||
const fingerprint2 = getDeviceFingerprint()
|
||||
|
||||
// 两个指纹不应该相同
|
||||
expect(fingerprint1.device_id).not.toBe(fingerprint2.device_id)
|
||||
})
|
||||
|
||||
it('should allow multiple clears without error', () => {
|
||||
clearDeviceFingerprint()
|
||||
clearDeviceFingerprint()
|
||||
clearDeviceFingerprint()
|
||||
|
||||
// 不应该抛出错误
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser detection', () => {
|
||||
it('should detect browser from user agent', () => {
|
||||
// 模拟不同的 User-Agent
|
||||
const testCases = [
|
||||
{ ua: 'Mozilla/5.0 Chrome/120.0', expected: 'Chrome' },
|
||||
{ ua: 'Mozilla/5.0 Firefox/120.0', expected: 'Firefox' },
|
||||
{ ua: 'Mozilla/5.0 Safari/120.0', expected: 'Safari' },
|
||||
{ ua: 'Mozilla/5.0 Edge/120.0', expected: 'Edge' },
|
||||
{ ua: 'Mozilla/5.0 Opera/120.0', expected: 'Opera' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ ua, expected }) => {
|
||||
// 注意:实际测试中 navigator.userAgent 是只读的
|
||||
// 这里主要验证函数能正常工作
|
||||
const fingerprint = getDeviceFingerprint()
|
||||
expect(fingerprint.device_browser).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('OS detection', () => {
|
||||
it('should detect OS from user agent', () => {
|
||||
// 类似浏览器检测,验证函数能正常工作
|
||||
const fingerprint = getDeviceFingerprint()
|
||||
expect(fingerprint.device_os).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('security considerations', () => {
|
||||
it('should not store fingerprint in localStorage', () => {
|
||||
getDeviceFingerprint()
|
||||
|
||||
// 设备指纹不应该存储在 localStorage
|
||||
const deviceId = localStorage.getItem('device_id')
|
||||
const fingerprint = localStorage.getItem('device_fingerprint')
|
||||
expect(deviceId).toBeFalsy() // null or undefined
|
||||
expect(fingerprint).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not store fingerprint in sessionStorage', () => {
|
||||
getDeviceFingerprint()
|
||||
|
||||
// 设备指纹不应该存储在 sessionStorage
|
||||
const deviceId = sessionStorage.getItem('device_id')
|
||||
const fingerprint = sessionStorage.getItem('device_fingerprint')
|
||||
expect(deviceId).toBeFalsy()
|
||||
expect(fingerprint).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
266
frontend/admin/src/lib/errors/index.test.ts
Normal file
266
frontend/admin/src/lib/errors/index.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AppError,
|
||||
ErrorType,
|
||||
isAppError,
|
||||
getErrorMessage,
|
||||
isFormValidationError,
|
||||
} from './index'
|
||||
|
||||
describe('lib/errors', () => {
|
||||
describe('ErrorType', () => {
|
||||
it('should have all error type constants', () => {
|
||||
expect(ErrorType.BUSINESS).toBe('BUSINESS')
|
||||
expect(ErrorType.NETWORK).toBe('NETWORK')
|
||||
expect(ErrorType.AUTH).toBe('AUTH')
|
||||
expect(ErrorType.FORBIDDEN).toBe('FORBIDDEN')
|
||||
expect(ErrorType.NOT_FOUND).toBe('NOT_FOUND')
|
||||
expect(ErrorType.VALIDATION).toBe('VALIDATION')
|
||||
expect(ErrorType.UNKNOWN).toBe('UNKNOWN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppError', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create an AppError with required fields', () => {
|
||||
const error = new AppError(1001, 'Test error')
|
||||
|
||||
expect(error.code).toBe(1001)
|
||||
expect(error.message).toBe('Test error')
|
||||
expect(error.name).toBe('AppError')
|
||||
expect(error.status).toBe(500) // default
|
||||
expect(error.type).toBe(ErrorType.BUSINESS) // default
|
||||
})
|
||||
|
||||
it('should create an AppError with options', () => {
|
||||
const cause = new Error('Original error')
|
||||
const error = new AppError(1001, 'Test error', {
|
||||
status: 400,
|
||||
type: ErrorType.VALIDATION,
|
||||
cause,
|
||||
})
|
||||
|
||||
expect(error.status).toBe(400)
|
||||
expect(error.type).toBe(ErrorType.VALIDATION)
|
||||
expect(error.cause).toBe(cause)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromResponse', () => {
|
||||
it('should create AUTH error for 401 status', () => {
|
||||
const error = AppError.fromResponse({ code: 401, message: 'Unauthorized' }, 401)
|
||||
|
||||
expect(error.type).toBe(ErrorType.AUTH)
|
||||
expect(error.status).toBe(401)
|
||||
expect(error.code).toBe(401)
|
||||
})
|
||||
|
||||
it('should create FORBIDDEN error for 403 status', () => {
|
||||
const error = AppError.fromResponse({ code: 403, message: 'Forbidden' }, 403)
|
||||
|
||||
expect(error.type).toBe(ErrorType.FORBIDDEN)
|
||||
expect(error.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should create NOT_FOUND error for 404 status', () => {
|
||||
const error = AppError.fromResponse({ code: 404, message: 'Not found' }, 404)
|
||||
|
||||
expect(error.type).toBe(ErrorType.NOT_FOUND)
|
||||
expect(error.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should create NETWORK error for 500+ status', () => {
|
||||
const error = AppError.fromResponse({ code: 500, message: 'Server error' }, 500)
|
||||
|
||||
expect(error.type).toBe(ErrorType.NETWORK)
|
||||
expect(error.status).toBe(500)
|
||||
})
|
||||
|
||||
it('should create BUSINESS error for other status codes', () => {
|
||||
const error = AppError.fromResponse({ code: 1001, message: 'Business error' }, 200)
|
||||
|
||||
expect(error.type).toBe(ErrorType.BUSINESS)
|
||||
expect(error.code).toBe(1001)
|
||||
})
|
||||
})
|
||||
|
||||
describe('static factory methods', () => {
|
||||
it('should create network error', () => {
|
||||
const cause = new Error('Network failed')
|
||||
const error = AppError.network('Network error', cause)
|
||||
|
||||
expect(error.type).toBe(ErrorType.NETWORK)
|
||||
expect(error.status).toBe(0)
|
||||
expect(error.code).toBe(0)
|
||||
expect(error.cause).toBe(cause)
|
||||
})
|
||||
|
||||
it('should create auth error with default message', () => {
|
||||
const error = AppError.auth()
|
||||
|
||||
expect(error.type).toBe(ErrorType.AUTH)
|
||||
expect(error.status).toBe(401)
|
||||
expect(error.message).toBe('请先登录')
|
||||
})
|
||||
|
||||
it('should create auth error with custom message', () => {
|
||||
const error = AppError.auth('Token expired')
|
||||
|
||||
expect(error.message).toBe('Token expired')
|
||||
})
|
||||
|
||||
it('should create forbidden error with default message', () => {
|
||||
const error = AppError.forbidden()
|
||||
|
||||
expect(error.type).toBe(ErrorType.FORBIDDEN)
|
||||
expect(error.status).toBe(403)
|
||||
expect(error.message).toBe('无权限访问')
|
||||
})
|
||||
|
||||
it('should create forbidden error with custom message', () => {
|
||||
const error = AppError.forbidden('Admin only')
|
||||
|
||||
expect(error.message).toBe('Admin only')
|
||||
})
|
||||
|
||||
it('should create validation error', () => {
|
||||
const error = AppError.validation('Invalid input')
|
||||
|
||||
expect(error.type).toBe(ErrorType.VALIDATION)
|
||||
expect(error.status).toBe(400)
|
||||
expect(error.message).toBe('Invalid input')
|
||||
})
|
||||
})
|
||||
|
||||
describe('instance methods', () => {
|
||||
it('should check if auth error', () => {
|
||||
const authError = AppError.auth()
|
||||
const otherError = new AppError(500, 'Server error')
|
||||
|
||||
expect(authError.isAuthError()).toBe(true)
|
||||
expect(otherError.isAuthError()).toBe(false)
|
||||
})
|
||||
|
||||
it('should check if forbidden error', () => {
|
||||
const forbiddenError = AppError.forbidden()
|
||||
const otherError = new AppError(500, 'Server error')
|
||||
|
||||
expect(forbiddenError.isForbidden()).toBe(true)
|
||||
expect(otherError.isForbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('should check if network error', () => {
|
||||
const networkError = AppError.network('Network failed')
|
||||
const otherError = new AppError(500, 'Server error')
|
||||
|
||||
expect(networkError.isNetworkError()).toBe(true)
|
||||
expect(otherError.isNetworkError()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUserMessage', () => {
|
||||
it('should return user-friendly message for NETWORK type', () => {
|
||||
const error = AppError.network('Network failed')
|
||||
expect(error.getUserMessage()).toBe('网络连接失败,请检查网络后重试')
|
||||
})
|
||||
|
||||
it('should return user-friendly message for AUTH type', () => {
|
||||
const error = AppError.auth('Token expired')
|
||||
expect(error.getUserMessage()).toBe('登录已过期,请重新登录')
|
||||
})
|
||||
|
||||
it('should return user-friendly message for FORBIDDEN type', () => {
|
||||
const error = AppError.forbidden('No access')
|
||||
expect(error.getUserMessage()).toBe('您没有权限执行此操作')
|
||||
})
|
||||
|
||||
it('should return user-friendly message for NOT_FOUND type', () => {
|
||||
const error = AppError.fromResponse({ code: 404, message: 'Not found' }, 404)
|
||||
expect(error.getUserMessage()).toBe('请求的资源不存在')
|
||||
})
|
||||
|
||||
it('should return original message for VALIDATION type', () => {
|
||||
const error = AppError.validation('邮箱格式不正确')
|
||||
expect(error.getUserMessage()).toBe('邮箱格式不正确')
|
||||
})
|
||||
|
||||
it('should return original message for BUSINESS type', () => {
|
||||
const error = new AppError(1001, '用户名已存在')
|
||||
expect(error.getUserMessage()).toBe('用户名已存在')
|
||||
})
|
||||
|
||||
it('should return fallback for empty message', () => {
|
||||
const error = new AppError(0, '', { type: ErrorType.UNKNOWN })
|
||||
expect(error.getUserMessage()).toBe('操作失败,请稍后重试')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAppError', () => {
|
||||
it('should return true for AppError instances', () => {
|
||||
const error = new AppError(1001, 'Test error')
|
||||
expect(isAppError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for Error instances', () => {
|
||||
const error = new Error('Test error')
|
||||
expect(isAppError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-error values', () => {
|
||||
expect(isAppError('error')).toBe(false)
|
||||
expect(isAppError(123)).toBe(false)
|
||||
expect(isAppError(null)).toBe(false)
|
||||
expect(isAppError(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('should return user message for AppError', () => {
|
||||
const error = AppError.auth('Token expired')
|
||||
expect(getErrorMessage(error, 'Fallback')).toBe('登录已过期,请重新登录')
|
||||
})
|
||||
|
||||
it('should return message for Error instances', () => {
|
||||
const error = new Error('Test error')
|
||||
expect(getErrorMessage(error, 'Fallback')).toBe('Test error')
|
||||
})
|
||||
|
||||
it('should return fallback for non-error values', () => {
|
||||
expect(getErrorMessage('string', 'Fallback')).toBe('Fallback')
|
||||
expect(getErrorMessage(null, 'Fallback')).toBe('Fallback')
|
||||
expect(getErrorMessage(undefined, 'Fallback')).toBe('Fallback')
|
||||
expect(getErrorMessage(123, 'Fallback')).toBe('Fallback')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFormValidationError', () => {
|
||||
it('should return true for form validation errors', () => {
|
||||
const error = { errorFields: [{ name: 'email' }] }
|
||||
expect(isFormValidationError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for empty errorFields', () => {
|
||||
const error = { errorFields: [] }
|
||||
expect(isFormValidationError(error)).toBe(true) // Empty array is still valid
|
||||
})
|
||||
|
||||
it('should return false for non-array errorFields', () => {
|
||||
const error = { errorFields: 'not an array' }
|
||||
expect(isFormValidationError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for objects without errorFields', () => {
|
||||
const error = { message: 'Error' }
|
||||
expect(isFormValidationError(error)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for non-object values', () => {
|
||||
expect(isFormValidationError('error')).toBe(false)
|
||||
expect(isFormValidationError(123)).toBe(false)
|
||||
expect(isFormValidationError(null)).toBe(false)
|
||||
expect(isFormValidationError(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
237
frontend/admin/src/lib/hooks/useBreadcrumbs.test.ts
Normal file
237
frontend/admin/src/lib/hooks/useBreadcrumbs.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useBreadcrumbs } from './useBreadcrumbs'
|
||||
|
||||
// Mock react-router-dom
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useLocation: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('lib/hooks/useBreadcrumbs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useBreadcrumbs', () => {
|
||||
it('should return empty array for root path', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for dashboard', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/dashboard',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '概览',
|
||||
path: undefined, // Last item has no path
|
||||
})
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for users page', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/users',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '用户管理',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for nested path', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/logs/login',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '审计日志',
|
||||
path: '/logs',
|
||||
})
|
||||
expect(result.current[1]).toEqual({
|
||||
title: '登录日志',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for profile security', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/profile/security',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '个人资料',
|
||||
path: '/profile',
|
||||
})
|
||||
expect(result.current[1]).toEqual({
|
||||
title: '安全设置',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip unknown path segments', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/unknown/path',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
// Unknown paths should return empty array
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for roles page', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/roles',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '角色管理',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for permissions page', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/permissions',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '权限管理',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for webhooks page', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/webhooks',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: 'Webhooks',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for import-export page', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/import-export',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '导入导出',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return breadcrumbs for operation logs', () => {
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/logs/operation',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useBreadcrumbs())
|
||||
expect(result.current).toHaveLength(2)
|
||||
expect(result.current[0]).toEqual({
|
||||
title: '审计日志',
|
||||
path: '/logs',
|
||||
})
|
||||
expect(result.current[1]).toEqual({
|
||||
title: '操作日志',
|
||||
path: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should memoize result based on pathname', () => {
|
||||
const location1 = {
|
||||
pathname: '/dashboard',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
}
|
||||
|
||||
vi.mocked(useLocation).mockReturnValue(location1)
|
||||
|
||||
const { result, rerender } = renderHook(() => useBreadcrumbs())
|
||||
const firstResult = result.current
|
||||
|
||||
// Rerender with same pathname
|
||||
rerender()
|
||||
expect(result.current).toBe(firstResult) // Should be same reference
|
||||
|
||||
// Change pathname
|
||||
vi.mocked(useLocation).mockReturnValue({
|
||||
...location1,
|
||||
pathname: '/users',
|
||||
})
|
||||
rerender()
|
||||
expect(result.current).not.toBe(firstResult) // Should be different reference
|
||||
})
|
||||
})
|
||||
})
|
||||
174
frontend/admin/src/lib/http/index.test.ts
Normal file
174
frontend/admin/src/lib/http/index.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import * as httpIndex from './index'
|
||||
import * as client from './client'
|
||||
import * as authSession from './auth-session'
|
||||
import * as errors from '@/lib/errors'
|
||||
|
||||
describe('lib/http/index', () => {
|
||||
describe('exports from client', () => {
|
||||
it('should export get function', () => {
|
||||
expect(httpIndex.get).toBeDefined()
|
||||
expect(typeof httpIndex.get).toBe('function')
|
||||
})
|
||||
|
||||
it('should export post function', () => {
|
||||
expect(httpIndex.post).toBeDefined()
|
||||
expect(typeof httpIndex.post).toBe('function')
|
||||
})
|
||||
|
||||
it('should export put function', () => {
|
||||
expect(httpIndex.put).toBeDefined()
|
||||
expect(typeof httpIndex.put).toBe('function')
|
||||
})
|
||||
|
||||
it('should export del function', () => {
|
||||
expect(httpIndex.del).toBeDefined()
|
||||
expect(typeof httpIndex.del).toBe('function')
|
||||
})
|
||||
|
||||
it('should export download function', () => {
|
||||
expect(httpIndex.download).toBeDefined()
|
||||
expect(typeof httpIndex.download).toBe('function')
|
||||
})
|
||||
|
||||
it('should export upload function', () => {
|
||||
expect(httpIndex.upload).toBeDefined()
|
||||
expect(typeof httpIndex.upload).toBe('function')
|
||||
})
|
||||
|
||||
it('should export request function', () => {
|
||||
expect(httpIndex.request).toBeDefined()
|
||||
expect(typeof httpIndex.request).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exports from auth-session', () => {
|
||||
it('should export getAccessToken function', () => {
|
||||
expect(httpIndex.getAccessToken).toBeDefined()
|
||||
expect(typeof httpIndex.getAccessToken).toBe('function')
|
||||
})
|
||||
|
||||
it('should export setAccessToken function', () => {
|
||||
expect(httpIndex.setAccessToken).toBeDefined()
|
||||
expect(typeof httpIndex.setAccessToken).toBe('function')
|
||||
})
|
||||
|
||||
it('should export clearAccessToken function', () => {
|
||||
expect(httpIndex.clearAccessToken).toBeDefined()
|
||||
expect(typeof httpIndex.clearAccessToken).toBe('function')
|
||||
})
|
||||
|
||||
it('should export isAccessTokenExpired function', () => {
|
||||
expect(httpIndex.isAccessTokenExpired).toBeDefined()
|
||||
expect(typeof httpIndex.isAccessTokenExpired).toBe('function')
|
||||
})
|
||||
|
||||
it('should export getCurrentUser function', () => {
|
||||
expect(httpIndex.getCurrentUser).toBeDefined()
|
||||
expect(typeof httpIndex.getCurrentUser).toBe('function')
|
||||
})
|
||||
|
||||
it('should export setCurrentUser function', () => {
|
||||
expect(httpIndex.setCurrentUser).toBeDefined()
|
||||
expect(typeof httpIndex.setCurrentUser).toBe('function')
|
||||
})
|
||||
|
||||
it('should export getCurrentRoles function', () => {
|
||||
expect(httpIndex.getCurrentRoles).toBeDefined()
|
||||
expect(typeof httpIndex.getCurrentRoles).toBe('function')
|
||||
})
|
||||
|
||||
it('should export setCurrentRoles function', () => {
|
||||
expect(httpIndex.setCurrentRoles).toBeDefined()
|
||||
expect(typeof httpIndex.setCurrentRoles).toBe('function')
|
||||
})
|
||||
|
||||
it('should export isAdmin function', () => {
|
||||
expect(httpIndex.isAdmin).toBeDefined()
|
||||
expect(typeof httpIndex.isAdmin).toBe('function')
|
||||
})
|
||||
|
||||
it('should export getRoleCodes function', () => {
|
||||
expect(httpIndex.getRoleCodes).toBeDefined()
|
||||
expect(typeof httpIndex.getRoleCodes).toBe('function')
|
||||
})
|
||||
|
||||
it('should export isAuthenticated function', () => {
|
||||
expect(httpIndex.isAuthenticated).toBeDefined()
|
||||
expect(typeof httpIndex.isAuthenticated).toBe('function')
|
||||
})
|
||||
|
||||
it('should export clearSession function', () => {
|
||||
expect(httpIndex.clearSession).toBeDefined()
|
||||
expect(typeof httpIndex.clearSession).toBe('function')
|
||||
})
|
||||
|
||||
it('should export isRefreshing function', () => {
|
||||
expect(httpIndex.isRefreshing).toBeDefined()
|
||||
expect(typeof httpIndex.isRefreshing).toBe('function')
|
||||
})
|
||||
|
||||
it('should export startRefreshing function', () => {
|
||||
expect(httpIndex.startRefreshing).toBeDefined()
|
||||
expect(typeof httpIndex.startRefreshing).toBe('function')
|
||||
})
|
||||
|
||||
it('should export endRefreshing function', () => {
|
||||
expect(httpIndex.endRefreshing).toBeDefined()
|
||||
expect(typeof httpIndex.endRefreshing).toBe('function')
|
||||
})
|
||||
|
||||
it('should export getRefreshPromise function', () => {
|
||||
expect(httpIndex.getRefreshPromise).toBeDefined()
|
||||
expect(typeof httpIndex.getRefreshPromise).toBe('function')
|
||||
})
|
||||
|
||||
it('should export setRefreshPromise function', () => {
|
||||
expect(httpIndex.setRefreshPromise).toBeDefined()
|
||||
expect(typeof httpIndex.setRefreshPromise).toBe('function')
|
||||
})
|
||||
|
||||
it('should export clearRefreshPromise function', () => {
|
||||
expect(httpIndex.clearRefreshPromise).toBeDefined()
|
||||
expect(typeof httpIndex.clearRefreshPromise).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exports from errors', () => {
|
||||
it('should export AppError class', () => {
|
||||
expect(httpIndex.AppError).toBeDefined()
|
||||
expect(typeof httpIndex.AppError).toBe('function')
|
||||
})
|
||||
|
||||
it('should export ErrorType constant', () => {
|
||||
expect(httpIndex.ErrorType).toBeDefined()
|
||||
expect(httpIndex.ErrorType.BUSINESS).toBe('BUSINESS')
|
||||
expect(httpIndex.ErrorType.NETWORK).toBe('NETWORK')
|
||||
expect(httpIndex.ErrorType.AUTH).toBe('AUTH')
|
||||
})
|
||||
|
||||
it('should export isAppError function', () => {
|
||||
expect(httpIndex.isAppError).toBeDefined()
|
||||
expect(typeof httpIndex.isAppError).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration', () => {
|
||||
it('should be able to create AppError from exported class', () => {
|
||||
const error = new httpIndex.AppError(1001, 'Test error')
|
||||
expect(error).toBeInstanceOf(httpIndex.AppError)
|
||||
expect(error.code).toBe(1001)
|
||||
expect(error.message).toBe('Test error')
|
||||
})
|
||||
|
||||
it('should be able to check error type with isAppError', () => {
|
||||
const error = new httpIndex.AppError(1001, 'Test error')
|
||||
expect(httpIndex.isAppError(error)).toBe(true)
|
||||
})
|
||||
|
||||
it('should have consistent ErrorType values', () => {
|
||||
expect(httpIndex.ErrorType).toEqual(errors.ErrorType)
|
||||
})
|
||||
})
|
||||
})
|
||||
168
frontend/admin/src/lib/storage/index.test.ts
Normal file
168
frontend/admin/src/lib/storage/index.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getRefreshToken,
|
||||
setRefreshToken,
|
||||
clearRefreshToken,
|
||||
hasRefreshToken,
|
||||
hasSessionPresenceCookie,
|
||||
} from './token-storage'
|
||||
|
||||
describe('lib/storage/token-storage', () => {
|
||||
beforeEach(() => {
|
||||
clearRefreshToken()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearRefreshToken()
|
||||
})
|
||||
|
||||
describe('getRefreshToken', () => {
|
||||
it('should return null initially', () => {
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('should return the token after setting', () => {
|
||||
setRefreshToken('test-token')
|
||||
expect(getRefreshToken()).toBe('test-token')
|
||||
})
|
||||
|
||||
it('should return null after clearing', () => {
|
||||
setRefreshToken('test-token')
|
||||
clearRefreshToken()
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setRefreshToken', () => {
|
||||
it('should set a valid token', () => {
|
||||
setRefreshToken('valid-token')
|
||||
expect(getRefreshToken()).toBe('valid-token')
|
||||
})
|
||||
|
||||
it('should handle null input', () => {
|
||||
setRefreshToken('existing-token')
|
||||
setRefreshToken(null)
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle undefined input', () => {
|
||||
setRefreshToken('existing-token')
|
||||
setRefreshToken(undefined)
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
setRefreshToken('existing-token')
|
||||
setRefreshToken('')
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle whitespace-only string', () => {
|
||||
setRefreshToken('existing-token')
|
||||
setRefreshToken(' ')
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('should trim whitespace from token', () => {
|
||||
setRefreshToken(' trimmed-token ')
|
||||
expect(getRefreshToken()).toBe('trimmed-token')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearRefreshToken', () => {
|
||||
it('should clear the token', () => {
|
||||
setRefreshToken('test-token')
|
||||
clearRefreshToken()
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('should be safe to call multiple times', () => {
|
||||
clearRefreshToken()
|
||||
clearRefreshToken()
|
||||
clearRefreshToken()
|
||||
expect(getRefreshToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasRefreshToken', () => {
|
||||
it('should return false initially', () => {
|
||||
expect(hasRefreshToken()).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true after setting token', () => {
|
||||
setRefreshToken('test-token')
|
||||
expect(hasRefreshToken()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false after clearing token', () => {
|
||||
setRefreshToken('test-token')
|
||||
clearRefreshToken()
|
||||
expect(hasRefreshToken()).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for empty token', () => {
|
||||
setRefreshToken('')
|
||||
expect(hasRefreshToken()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasSessionPresenceCookie', () => {
|
||||
it('should return false when cookie is not set', () => {
|
||||
// In test environment, document.cookie may be empty
|
||||
const result = hasSessionPresenceCookie()
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should detect session presence cookie', () => {
|
||||
// Set the cookie
|
||||
document.cookie = 'ums_session_present=1'
|
||||
|
||||
expect(hasSessionPresenceCookie()).toBe(true)
|
||||
|
||||
// Clean up
|
||||
document.cookie = 'ums_session_present=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
})
|
||||
|
||||
it('should return false when other cookies exist but not session cookie', () => {
|
||||
document.cookie = 'other_cookie=value'
|
||||
|
||||
expect(hasSessionPresenceCookie()).toBe(false)
|
||||
|
||||
// Clean up
|
||||
document.cookie = 'other_cookie=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
})
|
||||
|
||||
it('should handle multiple cookies', () => {
|
||||
document.cookie = 'cookie1=value1'
|
||||
document.cookie = 'ums_session_present=1'
|
||||
document.cookie = 'cookie2=value2'
|
||||
|
||||
expect(hasSessionPresenceCookie()).toBe(true)
|
||||
|
||||
// Clean up
|
||||
document.cookie = 'cookie1=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
document.cookie = 'ums_session_present=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
document.cookie = 'cookie2=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
})
|
||||
})
|
||||
|
||||
describe('security considerations', () => {
|
||||
it('should not store token in localStorage', () => {
|
||||
setRefreshToken('test-token')
|
||||
|
||||
// Token should not be in localStorage
|
||||
expect(localStorage.getItem('refreshToken')).toBeFalsy()
|
||||
expect(localStorage.getItem('refresh_token')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not store token in sessionStorage', () => {
|
||||
setRefreshToken('test-token')
|
||||
|
||||
// Token should not be in sessionStorage
|
||||
expect(sessionStorage.getItem('refreshToken')).toBeFalsy()
|
||||
expect(sessionStorage.getItem('refresh_token')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
125
frontend/admin/src/services/devices.test.ts
Normal file
125
frontend/admin/src/services/devices.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const putMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
del: delMock,
|
||||
}))
|
||||
|
||||
describe('devices service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
putMock.mockReset()
|
||||
delMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists user devices', async () => {
|
||||
const { listDevices } = await import('./devices')
|
||||
await listDevices({ page: 1, page_size: 10 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/devices', { page: 1, page_size: 10 })
|
||||
})
|
||||
|
||||
it('lists all devices for admin', async () => {
|
||||
const { listAllDevices } = await import('./devices')
|
||||
await listAllDevices({ page: 1, page_size: 20, status: 1 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/admin/devices', { page: 1, page_size: 20, status: 1 })
|
||||
})
|
||||
|
||||
it('gets a single device by id', async () => {
|
||||
const { getDevice } = await import('./devices')
|
||||
await getDevice(5)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/devices/5')
|
||||
})
|
||||
|
||||
it('deletes a user device', async () => {
|
||||
const { deleteDevice } = await import('./devices')
|
||||
await deleteDevice(3)
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/devices/3')
|
||||
})
|
||||
|
||||
it('deletes a device by admin', async () => {
|
||||
const { adminDeleteDevice } = await import('./devices')
|
||||
await adminDeleteDevice(7)
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/admin/devices/7')
|
||||
})
|
||||
|
||||
it('updates device status', async () => {
|
||||
const { updateDeviceStatus } = await import('./devices')
|
||||
await updateDeviceStatus(2, 1)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/devices/2/status', { status: 1 })
|
||||
})
|
||||
|
||||
it('updates device status by admin', async () => {
|
||||
const { adminUpdateDeviceStatus } = await import('./devices')
|
||||
await adminUpdateDeviceStatus(4, 0)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/admin/devices/4/status', { status: 0 })
|
||||
})
|
||||
|
||||
it('trusts a device', async () => {
|
||||
const { trustDevice } = await import('./devices')
|
||||
await trustDevice(1, '30d')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/devices/1/trust', { trust_duration: '30d' })
|
||||
})
|
||||
|
||||
it('trusts a device by admin', async () => {
|
||||
const { adminTrustDevice } = await import('./devices')
|
||||
await adminTrustDevice(6, '7d')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/admin/devices/6/trust', { trust_duration: '7d' })
|
||||
})
|
||||
|
||||
it('trusts a device by device id string', async () => {
|
||||
const { trustDeviceByDeviceId } = await import('./devices')
|
||||
await trustDeviceByDeviceId('device-abc-123', '30d')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
'/devices/by-device-id/device-abc-123/trust',
|
||||
{ trust_duration: '30d' },
|
||||
)
|
||||
})
|
||||
|
||||
it('untrusts a device', async () => {
|
||||
const { untrustDevice } = await import('./devices')
|
||||
await untrustDevice(2)
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/devices/2/trust')
|
||||
})
|
||||
|
||||
it('untrusts a device by admin', async () => {
|
||||
const { adminUntrustDevice } = await import('./devices')
|
||||
await adminUntrustDevice(8)
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/admin/devices/8/trust')
|
||||
})
|
||||
|
||||
it('gets my trusted devices', async () => {
|
||||
const { getMyTrustedDevices } = await import('./devices')
|
||||
await getMyTrustedDevices()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/devices/me/trusted')
|
||||
})
|
||||
|
||||
it('logs out other devices', async () => {
|
||||
const { logoutOtherDevices } = await import('./devices')
|
||||
await logoutOtherDevices('current-device-id')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/devices/me/logout-others', {
|
||||
current_device_id: 'current-device-id',
|
||||
})
|
||||
})
|
||||
})
|
||||
120
frontend/admin/src/services/import-export.test.ts
Normal file
120
frontend/admin/src/services/import-export.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const downloadMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
download: downloadMock,
|
||||
post: postMock,
|
||||
}))
|
||||
|
||||
describe('import-export service', () => {
|
||||
beforeEach(() => {
|
||||
downloadMock.mockReset()
|
||||
postMock.mockReset()
|
||||
})
|
||||
|
||||
it('exports users with specified format and fields', async () => {
|
||||
const blob = new Blob(['csv,data'], { type: 'text/csv' })
|
||||
downloadMock.mockResolvedValue(blob)
|
||||
|
||||
const clickMock = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
const createObjectURLMock = vi.fn(() => 'blob:mock')
|
||||
const revokeObjectURLMock = vi.fn()
|
||||
|
||||
Object.defineProperty(window.URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectURLMock,
|
||||
})
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: revokeObjectURLMock,
|
||||
})
|
||||
|
||||
const { exportUsers } = await import('./import-export')
|
||||
await exportUsers({
|
||||
format: 'csv',
|
||||
fields: ['id', 'username', 'email'],
|
||||
keyword: 'alice',
|
||||
status: 1,
|
||||
})
|
||||
|
||||
expect(downloadMock).toHaveBeenCalledWith('/admin/users/export', {
|
||||
format: 'csv',
|
||||
fields: 'id,username,email',
|
||||
keyword: 'alice',
|
||||
status: 1,
|
||||
})
|
||||
|
||||
expect(createObjectURLMock).toHaveBeenCalled()
|
||||
expect(clickMock).toHaveBeenCalled()
|
||||
expect(revokeObjectURLMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads import template', async () => {
|
||||
const blob = new Blob(['template,data'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
downloadMock.mockResolvedValue(blob)
|
||||
|
||||
const clickMock = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
|
||||
const createObjectURLMock = vi.fn(() => 'blob:mock')
|
||||
const revokeObjectURLMock = vi.fn()
|
||||
|
||||
Object.defineProperty(window.URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectURLMock,
|
||||
})
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: revokeObjectURLMock,
|
||||
})
|
||||
|
||||
const { downloadImportTemplate } = await import('./import-export')
|
||||
await downloadImportTemplate('xlsx')
|
||||
|
||||
expect(downloadMock).toHaveBeenCalledWith('/admin/users/import/template', { format: 'xlsx' })
|
||||
|
||||
expect(createObjectURLMock).toHaveBeenCalled()
|
||||
expect(clickMock).toHaveBeenCalled()
|
||||
expect(revokeObjectURLMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('imports users from csv file', async () => {
|
||||
const file = new File(['username,email'], 'users.csv', { type: 'text/csv' })
|
||||
const importResult = {
|
||||
success_count: 10,
|
||||
fail_count: 2,
|
||||
errors: ['Row 3: Invalid email', 'Row 7: Missing username'],
|
||||
message: 'Import completed with errors',
|
||||
}
|
||||
postMock.mockResolvedValue(importResult)
|
||||
|
||||
const { importUsers } = await import('./import-export')
|
||||
const result = await importUsers(file)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/admin/users/import', expect.any(FormData))
|
||||
const payload = postMock.mock.calls[0][1] as FormData
|
||||
expect(payload.get('file')).toBe(file)
|
||||
expect(result).toEqual(importResult)
|
||||
})
|
||||
|
||||
it('imports users from xlsx file', async () => {
|
||||
const file = new File(['xlsx,data'], 'users.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
const importResult = {
|
||||
success_count: 50,
|
||||
fail_count: 0,
|
||||
errors: [],
|
||||
message: 'Import successful',
|
||||
}
|
||||
postMock.mockResolvedValue(importResult)
|
||||
|
||||
const { importUsers } = await import('./import-export')
|
||||
const result = await importUsers(file)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/admin/users/import', expect.any(FormData))
|
||||
const payload = postMock.mock.calls[0][1] as FormData
|
||||
expect(payload.get('file')).toBe(file)
|
||||
expect(result).toEqual(importResult)
|
||||
})
|
||||
})
|
||||
76
frontend/admin/src/services/login-logs.test.ts
Normal file
76
frontend/admin/src/services/login-logs.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const downloadMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
download: downloadMock,
|
||||
}))
|
||||
|
||||
describe('login-logs service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
downloadMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists login logs with pagination', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
list: [{ id: 1, status: 1, login_type: 1 }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 20,
|
||||
})
|
||||
|
||||
const { listLoginLogs } = await import('./login-logs')
|
||||
const result = await listLoginLogs({ page: 1, page_size: 20 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/login', { page: 1, page_size: 20 })
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 1, status: 1, login_type: 1 }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists login logs with filters', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
list: [{ id: 2, status: 0 }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
size: 10,
|
||||
})
|
||||
|
||||
const { listLoginLogs } = await import('./login-logs')
|
||||
const result = await listLoginLogs({ page: 2, page_size: 10, status: 0 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/login', { page: 2, page_size: 10, status: 0 })
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 2, status: 0 }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists my login logs', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
list: [{ id: 3, status: 1 }],
|
||||
total: 3,
|
||||
page: 1,
|
||||
size: 5,
|
||||
})
|
||||
|
||||
const { listMyLoginLogs } = await import('./login-logs')
|
||||
const result = await listMyLoginLogs({ page: 1, page_size: 5 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/login/me', { page: 1, page_size: 5 })
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 3, status: 1 }],
|
||||
total: 3,
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
73
frontend/admin/src/services/operation-logs.test.ts
Normal file
73
frontend/admin/src/services/operation-logs.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
}))
|
||||
|
||||
describe('operation-logs service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists operation logs with pagination', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
list: [{ id: 1, operation_name: 'create_user' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 20,
|
||||
})
|
||||
|
||||
const { listOperationLogs } = await import('./operation-logs')
|
||||
const result = await listOperationLogs({ page: 1, page_size: 20 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/operation', { page: 1, page_size: 20 })
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 1, operation_name: 'create_user' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists operation logs with filters', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
list: [{ id: 2, operation_name: 'update_user', method: 'PUT' }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
size: 10,
|
||||
})
|
||||
|
||||
const { listOperationLogs } = await import('./operation-logs')
|
||||
const result = await listOperationLogs({ page: 2, page_size: 10, method: 'PUT' })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/operation', { page: 2, page_size: 10, method: 'PUT' })
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 2, operation_name: 'update_user', method: 'PUT' }],
|
||||
total: 1,
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists my operation logs', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
list: [{ id: 3, operation_name: 'login' }],
|
||||
total: 5,
|
||||
page: 1,
|
||||
size: 10,
|
||||
})
|
||||
|
||||
const { listMyOperationLogs } = await import('./operation-logs')
|
||||
const result = await listMyOperationLogs({ page: 1, page_size: 10 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/logs/operation/me', { page: 1, page_size: 10 })
|
||||
expect(result).toEqual({
|
||||
items: [{ id: 3, operation_name: 'login' }],
|
||||
total: 5,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
100
frontend/admin/src/services/permissions.test.ts
Normal file
100
frontend/admin/src/services/permissions.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const putMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
del: delMock,
|
||||
}))
|
||||
|
||||
describe('permissions service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
putMock.mockReset()
|
||||
delMock.mockReset()
|
||||
})
|
||||
|
||||
it('gets permission tree', async () => {
|
||||
const mockTree = [
|
||||
{ id: 1, name: 'dashboard', children: [{ id: 2, name: 'view' }] },
|
||||
]
|
||||
getMock.mockResolvedValue(mockTree)
|
||||
|
||||
const { getPermissionTree } = await import('./permissions')
|
||||
const result = await getPermissionTree()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
|
||||
expect(result).toEqual(mockTree)
|
||||
})
|
||||
|
||||
it('lists all permissions', async () => {
|
||||
const mockPermissions = [
|
||||
{ id: 1, name: 'view dashboard', code: 'dashboard:view' },
|
||||
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit' },
|
||||
]
|
||||
getMock.mockResolvedValue(mockPermissions)
|
||||
|
||||
const { listPermissions } = await import('./permissions')
|
||||
const result = await listPermissions()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions')
|
||||
expect(result).toEqual(mockPermissions)
|
||||
})
|
||||
|
||||
it('gets a single permission', async () => {
|
||||
getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view' })
|
||||
|
||||
const { getPermission } = await import('./permissions')
|
||||
const result = await getPermission(5)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/permissions/5')
|
||||
expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view' })
|
||||
})
|
||||
|
||||
it('creates a permission', async () => {
|
||||
const newPermission = { name: 'new permission', code: 'new:code', type: 'button' as const }
|
||||
const created = { id: 10, ...newPermission }
|
||||
postMock.mockResolvedValue(created)
|
||||
|
||||
const { createPermission } = await import('./permissions')
|
||||
const result = await createPermission(newPermission)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
|
||||
expect(result).toEqual(created)
|
||||
})
|
||||
|
||||
it('updates a permission', async () => {
|
||||
const updateData = { name: 'updated name' }
|
||||
putMock.mockResolvedValue({ id: 3, ...updateData })
|
||||
|
||||
const { updatePermission } = await import('./permissions')
|
||||
const result = await updatePermission(3, updateData)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/permissions/3', updateData)
|
||||
expect(result).toEqual({ id: 3, name: 'updated name' })
|
||||
})
|
||||
|
||||
it('deletes a permission', async () => {
|
||||
delMock.mockResolvedValue(undefined)
|
||||
|
||||
const { deletePermission } = await import('./permissions')
|
||||
await deletePermission(7)
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/permissions/7')
|
||||
})
|
||||
|
||||
it('updates permission status', async () => {
|
||||
putMock.mockResolvedValue(undefined)
|
||||
|
||||
const { updatePermissionStatus } = await import('./permissions')
|
||||
await updatePermissionStatus(4, 0)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/permissions/4/status', { status: 0 })
|
||||
})
|
||||
})
|
||||
127
frontend/admin/src/services/profile.test.ts
Normal file
127
frontend/admin/src/services/profile.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const putMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
}))
|
||||
|
||||
vi.mock('./users', () => ({
|
||||
getUserRoles: vi.fn().mockResolvedValue([{ id: 2, name: '管理员' }]),
|
||||
}))
|
||||
|
||||
describe('profile service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
putMock.mockReset()
|
||||
})
|
||||
|
||||
it('gets current user profile with roles', async () => {
|
||||
getMock
|
||||
.mockResolvedValueOnce({ id: 1, username: 'admin', nickname: 'Admin' })
|
||||
.mockResolvedValueOnce([{ id: 2, name: '管理员' }])
|
||||
|
||||
const { getCurrentProfile } = await import('./profile')
|
||||
const result = await getCurrentProfile(1)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/users/1')
|
||||
expect(result).toEqual({
|
||||
user: { id: 1, username: 'admin', nickname: 'Admin' },
|
||||
roles: [{ id: 2, name: '管理员' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('updates user profile', async () => {
|
||||
const updateData = { nickname: 'New Nickname' }
|
||||
putMock.mockResolvedValue({ id: 1, ...updateData })
|
||||
|
||||
const { updateProfile } = await import('./profile')
|
||||
const result = await updateProfile(1, updateData)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/users/1', updateData)
|
||||
expect(result).toEqual({ id: 1, nickname: 'New Nickname' })
|
||||
})
|
||||
|
||||
it('uploads avatar', async () => {
|
||||
const file = new File(['avatar'], 'avatar.png', { type: 'image/png' })
|
||||
const uploadResponse = {
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
thumbnail: 'https://example.com/avatar_thumb.png',
|
||||
message: 'Upload success',
|
||||
}
|
||||
postMock.mockResolvedValue(uploadResponse)
|
||||
|
||||
const { uploadAvatar } = await import('./profile')
|
||||
const result = await uploadAvatar(1, file)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/users/1/avatar', expect.any(FormData))
|
||||
const payload = postMock.mock.calls[0][1] as FormData
|
||||
expect(payload.get('avatar')).toBe(file)
|
||||
expect(result).toEqual(uploadResponse)
|
||||
})
|
||||
|
||||
it('updates password', async () => {
|
||||
putMock.mockResolvedValue(undefined)
|
||||
|
||||
const { updatePassword } = await import('./profile')
|
||||
await updatePassword(1, {
|
||||
current_password: 'OldPass123',
|
||||
new_password: 'NewPass123',
|
||||
confirm_password: 'NewPass123',
|
||||
})
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
|
||||
current_password: 'OldPass123',
|
||||
new_password: 'NewPass123',
|
||||
confirm_password: 'NewPass123',
|
||||
})
|
||||
})
|
||||
|
||||
it('gets TOTP status', async () => {
|
||||
getMock.mockResolvedValue({ totp_enabled: true })
|
||||
|
||||
const { getTOTPStatus } = await import('./profile')
|
||||
const result = await getTOTPStatus()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/auth/2fa/status')
|
||||
expect(result).toEqual({ totp_enabled: true })
|
||||
})
|
||||
|
||||
it('gets TOTP setup data', async () => {
|
||||
const setupData = {
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
qr_code_base64: 'data:image/png;base64,abc123',
|
||||
recovery_codes: ['code1', 'code2', 'code3'],
|
||||
}
|
||||
getMock.mockResolvedValue(setupData)
|
||||
|
||||
const { getTOTPSetup } = await import('./profile')
|
||||
const result = await getTOTPSetup()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/auth/2fa/setup')
|
||||
expect(result).toEqual(setupData)
|
||||
})
|
||||
|
||||
it('enables TOTP', async () => {
|
||||
postMock.mockResolvedValue(undefined)
|
||||
|
||||
const { enableTOTP } = await import('./profile')
|
||||
await enableTOTP('123456')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/2fa/enable', { code: '123456' })
|
||||
})
|
||||
|
||||
it('disables TOTP', async () => {
|
||||
postMock.mockResolvedValue(undefined)
|
||||
|
||||
const { disableTOTP } = await import('./profile')
|
||||
await disableTOTP('654321')
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/2fa/disable', { code: '654321' })
|
||||
})
|
||||
})
|
||||
121
frontend/admin/src/services/roles.test.ts
Normal file
121
frontend/admin/src/services/roles.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
const postMock = vi.fn()
|
||||
const putMock = vi.fn()
|
||||
const delMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
del: delMock,
|
||||
}))
|
||||
|
||||
describe('roles service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
putMock.mockReset()
|
||||
delMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists roles with pagination', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, name: '管理员', code: 'admin' },
|
||||
{ id: 2, name: '用户', code: 'user' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
const { listRoles } = await import('./roles')
|
||||
const result = await listRoles({ page: 1, page_size: 20 })
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/roles', { page: 1, page_size: 20 })
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
{ id: 1, name: '管理员', code: 'admin' },
|
||||
{ id: 2, name: '用户', code: 'user' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('gets a single role', async () => {
|
||||
getMock.mockResolvedValue({ id: 3, name: '审计员', code: 'auditor' })
|
||||
|
||||
const { getRole } = await import('./roles')
|
||||
const result = await getRole(3)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/roles/3')
|
||||
expect(result).toEqual({ id: 3, name: '审计员', code: 'auditor' })
|
||||
})
|
||||
|
||||
it('creates a role', async () => {
|
||||
const roleData = { name: '新角色', code: 'new_role' }
|
||||
const created = { id: 10, ...roleData }
|
||||
postMock.mockResolvedValue(created)
|
||||
|
||||
const { createRole } = await import('./roles')
|
||||
const result = await createRole(roleData)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/roles', roleData)
|
||||
expect(result).toEqual(created)
|
||||
})
|
||||
|
||||
it('updates a role', async () => {
|
||||
const updateData = { name: '更新的角色', description: '新描述' }
|
||||
putMock.mockResolvedValue({ id: 5, ...updateData })
|
||||
|
||||
const { updateRole } = await import('./roles')
|
||||
const result = await updateRole(5, updateData)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/roles/5', updateData)
|
||||
expect(result).toEqual({ id: 5, ...updateData })
|
||||
})
|
||||
|
||||
it('deletes a role', async () => {
|
||||
delMock.mockResolvedValue(undefined)
|
||||
|
||||
const { deleteRole } = await import('./roles')
|
||||
await deleteRole(7)
|
||||
|
||||
expect(delMock).toHaveBeenCalledWith('/roles/7')
|
||||
})
|
||||
|
||||
it('updates role status', async () => {
|
||||
putMock.mockResolvedValue(undefined)
|
||||
|
||||
const { updateRoleStatus } = await import('./roles')
|
||||
await updateRoleStatus(4, 0)
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/roles/4/status', { status: 0 })
|
||||
})
|
||||
|
||||
it('gets role permissions', async () => {
|
||||
getMock.mockResolvedValue([
|
||||
{ id: 1, name: 'view' },
|
||||
{ id: 2, name: 'edit' },
|
||||
])
|
||||
|
||||
const { getRolePermissions } = await import('./roles')
|
||||
const result = await getRolePermissions(3)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/roles/3/permissions')
|
||||
expect(result).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('assigns permissions to a role', async () => {
|
||||
putMock.mockResolvedValue(undefined)
|
||||
|
||||
const { assignRolePermissions } = await import('./roles')
|
||||
await assignRolePermissions(3, [1, 2, 3])
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('/roles/3/permissions', { permission_ids: [1, 2, 3] })
|
||||
})
|
||||
})
|
||||
58
frontend/admin/src/services/settings.test.ts
Normal file
58
frontend/admin/src/services/settings.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
}))
|
||||
|
||||
describe('settings service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
})
|
||||
|
||||
it('gets system settings', async () => {
|
||||
const mockSettings = {
|
||||
data: {
|
||||
system: {
|
||||
name: 'UserSystem',
|
||||
version: '1.0.0',
|
||||
environment: 'production',
|
||||
description: 'User management system',
|
||||
},
|
||||
security: {
|
||||
password_min_length: 8,
|
||||
password_require_uppercase: true,
|
||||
password_require_lowercase: true,
|
||||
password_require_numbers: true,
|
||||
password_require_symbols: true,
|
||||
password_history: 5,
|
||||
totp_enabled: true,
|
||||
login_fail_lock: true,
|
||||
login_fail_threshold: 5,
|
||||
login_fail_duration: 30,
|
||||
session_timeout: 3600,
|
||||
device_trust_duration: 2592000,
|
||||
},
|
||||
features: {
|
||||
email_verification: true,
|
||||
phone_verification: false,
|
||||
oauth_providers: ['google', 'github'],
|
||||
sso_enabled: false,
|
||||
operation_log_enabled: true,
|
||||
login_log_enabled: true,
|
||||
data_export_enabled: true,
|
||||
data_import_enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
getMock.mockResolvedValue(mockSettings)
|
||||
|
||||
const { getSettings } = await import('./settings')
|
||||
const result = await getSettings()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/admin/settings')
|
||||
expect(result).toEqual(mockSettings.data)
|
||||
})
|
||||
})
|
||||
49
frontend/admin/src/services/stats.test.ts
Normal file
49
frontend/admin/src/services/stats.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/http/client', () => ({
|
||||
get: getMock,
|
||||
}))
|
||||
|
||||
describe('stats service', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
})
|
||||
|
||||
it('gets dashboard stats', async () => {
|
||||
const mockStats = {
|
||||
total_users: 100,
|
||||
active_users: 75,
|
||||
new_users_today: 5,
|
||||
total_devices: 200,
|
||||
trusted_devices: 150,
|
||||
}
|
||||
|
||||
getMock.mockResolvedValue(mockStats)
|
||||
|
||||
const { getDashboardStats } = await import('./stats')
|
||||
const result = await getDashboardStats()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/admin/stats/dashboard')
|
||||
expect(result).toEqual(mockStats)
|
||||
})
|
||||
|
||||
it('gets user stats', async () => {
|
||||
const mockUserStats = {
|
||||
total: 100,
|
||||
active: 75,
|
||||
inactive: 25,
|
||||
verified: 80,
|
||||
unverified: 20,
|
||||
}
|
||||
|
||||
getMock.mockResolvedValue(mockUserStats)
|
||||
|
||||
const { getUserStats } = await import('./stats')
|
||||
const result = await getUserStats()
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/admin/stats/users')
|
||||
expect(result).toEqual(mockUserStats)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user