test: add Stage 1 lib and Stage 2 services test coverage

Add comprehensive unit tests for:
- lib layer: config, device-fingerprint, errors, storage, hooks/useBreadcrumbs, http
- services layer: devices, login-logs, operation-logs, permissions, profile, roles, settings, stats, import-export

All 491 tests pass across 74 test files.
This commit is contained in:
2026-04-17 23:59:15 +08:00
parent 582ad7a069
commit 40d146b6aa
15 changed files with 1900 additions and 0 deletions

View 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',
})
})
})

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

View 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,
})
})
})

View 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,
})
})
})

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

View 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' })
})
})

View 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] })
})
})

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

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