Bläddra i källkod

test: add fixtures, mocks and improve test infrastructure

- Create test fixtures with mock data for all modules (auth, machines, cameras, stats)
- Add API mock utilities for consistent test data usage
- Update existing tests to use shared fixtures
- Add auth utility tests (10 tests)
- Add VideoPlayer component tests (23 tests)
- Total tests increased from 37 to 70

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
yb 3 veckor sedan
förälder
incheckning
4f30b9c3fa

+ 211 - 0
tests/fixtures/index.ts

@@ -0,0 +1,211 @@
+/**
+ * 测试数据 Fixtures
+ * 提供各模块的模拟数据
+ */
+
+import type {
+  AdminInfo,
+  LoginResponse,
+  MachineDTO,
+  CameraInfoDTO,
+  ChannelInfoDTO,
+  DashboardStatsDTO
+} from '@/types'
+
+// ==================== 用户/认证数据 ====================
+
+export const mockAdminInfo: AdminInfo = {
+  id: 1,
+  username: 'admin',
+  nickname: '管理员',
+  role: 'admin',
+  lastLoginAt: '2024-01-01T00:00:00Z'
+}
+
+export const mockLoginResponse: LoginResponse = {
+  token: 'mock-jwt-token-12345',
+  tokenType: 'Bearer',
+  expiresIn: 3600,
+  refreshToken: 'mock-refresh-token-67890',
+  admin: mockAdminInfo
+}
+
+export const mockUsers = [
+  { ...mockAdminInfo },
+  {
+    id: 2,
+    username: 'operator1',
+    nickname: '操作员1',
+    role: 'operator',
+    lastLoginAt: '2024-01-02T00:00:00Z'
+  },
+  {
+    id: 3,
+    username: 'viewer1',
+    nickname: '观察员1',
+    role: 'viewer',
+    lastLoginAt: '2024-01-03T00:00:00Z'
+  }
+]
+
+// ==================== 机器数据 ====================
+
+export const mockMachines: MachineDTO[] = [
+  {
+    id: 1,
+    machineId: 'machine-001',
+    name: '一号机',
+    location: '一楼大厅',
+    description: '主入口监控',
+    enabled: true,
+    cameraCount: 3,
+    createdAt: '2024-01-01 00:00:00',
+    updatedAt: '2024-01-01 00:00:00'
+  },
+  {
+    id: 2,
+    machineId: 'machine-002',
+    name: '二号机',
+    location: '二楼走廊',
+    description: '走廊监控',
+    enabled: true,
+    cameraCount: 2,
+    createdAt: '2024-01-02 00:00:00',
+    updatedAt: '2024-01-02 00:00:00'
+  },
+  {
+    id: 3,
+    machineId: 'machine-003',
+    name: '三号机',
+    location: '停车场',
+    description: '停车场监控',
+    enabled: false,
+    cameraCount: 4,
+    createdAt: '2024-01-03 00:00:00',
+    updatedAt: '2024-01-03 00:00:00'
+  }
+]
+
+// ==================== 通道数据 ====================
+
+export const mockChannels: ChannelInfoDTO[] = [
+  {
+    id: 1,
+    channelId: 'ch-001',
+    name: '通道1-主视角',
+    rtspUrl: 'rtsp://192.168.1.100:554/stream1',
+    defaultView: true,
+    status: 'ONLINE'
+  },
+  {
+    id: 2,
+    channelId: 'ch-002',
+    name: '通道2-侧视角',
+    rtspUrl: 'rtsp://192.168.1.100:554/stream2',
+    defaultView: false,
+    status: 'ONLINE'
+  },
+  {
+    id: 3,
+    channelId: 'ch-003',
+    name: '通道3-广角',
+    rtspUrl: 'rtsp://192.168.1.100:554/stream3',
+    defaultView: false,
+    status: 'OFFLINE'
+  }
+]
+
+// ==================== 摄像头数据 ====================
+
+export const mockCameras: CameraInfoDTO[] = [
+  {
+    id: 1,
+    cameraId: 'cam-001',
+    name: '大厅摄像头1',
+    ip: '192.168.1.100',
+    port: 80,
+    username: 'admin',
+    brand: 'hikvision',
+    capability: 'ptz_enabled',
+    status: 'ONLINE',
+    machineId: 'machine-001',
+    machineName: '一号机',
+    enabled: true,
+    channels: [mockChannels[0], mockChannels[1]],
+    createdAt: '2024-01-01 00:00:00',
+    updatedAt: '2024-01-01 00:00:00'
+  },
+  {
+    id: 2,
+    cameraId: 'cam-002',
+    name: '大厅摄像头2',
+    ip: '192.168.1.101',
+    port: 80,
+    username: 'admin',
+    brand: 'dahua',
+    capability: 'switch_only',
+    status: 'ONLINE',
+    machineId: 'machine-001',
+    machineName: '一号机',
+    enabled: true,
+    channels: [mockChannels[2]],
+    createdAt: '2024-01-02 00:00:00',
+    updatedAt: '2024-01-02 00:00:00'
+  },
+  {
+    id: 3,
+    cameraId: 'cam-003',
+    name: '走廊摄像头',
+    ip: '192.168.1.102',
+    port: 80,
+    username: 'admin',
+    brand: 'hikvision',
+    capability: 'ptz_enabled',
+    status: 'OFFLINE',
+    machineId: 'machine-002',
+    machineName: '二号机',
+    enabled: false,
+    channels: [],
+    createdAt: '2024-01-03 00:00:00',
+    updatedAt: '2024-01-03 00:00:00'
+  }
+]
+
+// ==================== 统计数据 ====================
+
+export const mockDashboardStats: DashboardStatsDTO = {
+  machineTotal: 3,
+  machineEnabled: 2,
+  cameraTotal: 9,
+  cameraOnline: 7,
+  cameraOffline: 2,
+  channelTotal: 18
+}
+
+// ==================== API 响应包装 ====================
+
+export function wrapResponse<T>(data: T, code = 200, message = 'success') {
+  return {
+    code,
+    message,
+    data,
+    timestamp: Date.now(),
+    traceId: `trace-${Date.now()}`
+  }
+}
+
+export function wrapPageResponse<T>(rows: T[], total?: number) {
+  return wrapResponse({
+    rows,
+    total: total ?? rows.length
+  })
+}
+
+export function wrapErrorResponse(message: string, code = 400) {
+  return {
+    code,
+    message,
+    data: null,
+    timestamp: Date.now()
+  }
+}

+ 252 - 0
tests/mocks/api.ts

@@ -0,0 +1,252 @@
+/**
+ * API Mock 工具
+ * 用于单元测试中模拟 API 响应
+ */
+
+import { vi } from 'vitest'
+import * as fixtures from '../fixtures'
+
+// Mock request 模块的类型
+type MockedRequest = {
+  get: ReturnType<typeof vi.fn>
+  post: ReturnType<typeof vi.fn>
+  put: ReturnType<typeof vi.fn>
+  del: ReturnType<typeof vi.fn>
+}
+
+/**
+ * 创建 API mock
+ */
+export function createApiMock() {
+  return {
+    get: vi.fn(),
+    post: vi.fn(),
+    put: vi.fn(),
+    del: vi.fn()
+  }
+}
+
+/**
+ * 设置登录 API mock
+ */
+export function mockLoginApi(request: MockedRequest) {
+  // login
+  request.post.mockImplementation((url: string, data?: any) => {
+    if (url === '/admin/auth/login') {
+      if (data?.username === 'admin' && data?.password === 'admin123') {
+        return Promise.resolve(fixtures.wrapResponse(fixtures.mockLoginResponse))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('用户名或密码错误', 401))
+    }
+    if (url === '/admin/auth/logout') {
+      return Promise.resolve(fixtures.wrapResponse(null))
+    }
+    if (url === '/admin/auth/password') {
+      if (data?.oldPassword === 'admin123') {
+        return Promise.resolve(fixtures.wrapResponse(null, 200, '密码修改成功'))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('原密码错误', 400))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+
+  // getInfo
+  request.get.mockImplementation((url: string) => {
+    if (url === '/admin/auth/info') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockAdminInfo))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+}
+
+/**
+ * 设置机器 API mock
+ */
+export function mockMachineApi(request: MockedRequest) {
+  request.get.mockImplementation((url: string, params?: any) => {
+    if (url === '/admin/machines/list') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockMachines))
+    }
+    if (url === '/admin/machines/detail') {
+      const machine = fixtures.mockMachines.find((m) => m.id === params?.id)
+      if (machine) {
+        return Promise.resolve(fixtures.wrapResponse(machine))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('机器不存在', 404))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+
+  request.post.mockImplementation((url: string, data?: any, config?: any) => {
+    if (url === '/admin/machines/add') {
+      const newMachine: typeof fixtures.mockMachines[0] = {
+        id: fixtures.mockMachines.length + 1,
+        machineId: data.machineId,
+        name: data.name,
+        location: data.location || '',
+        description: data.description || '',
+        enabled: true,
+        cameraCount: 0,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString()
+      }
+      return Promise.resolve(fixtures.wrapResponse(newMachine))
+    }
+    if (url === '/admin/machines/update') {
+      const machine = fixtures.mockMachines.find((m) => m.id === data?.id)
+      if (machine) {
+        return Promise.resolve(fixtures.wrapResponse({ ...machine, ...data }))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('机器不存在', 404))
+    }
+    if (url === '/admin/machines/delete') {
+      const id = config?.params?.id
+      const machine = fixtures.mockMachines.find((m) => m.id === id)
+      if (machine) {
+        if (machine.cameraCount > 0) {
+          return Promise.resolve(fixtures.wrapErrorResponse('该机器下存在摄像头,无法删除', 400))
+        }
+        return Promise.resolve(fixtures.wrapResponse(null))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('机器不存在', 404))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+}
+
+/**
+ * 设置摄像头 API mock
+ */
+export function mockCameraApi(request: MockedRequest) {
+  request.get.mockImplementation((url: string, params?: any) => {
+    if (url === '/admin/cameras/list') {
+      let cameras = fixtures.mockCameras
+      if (params?.machineId) {
+        cameras = cameras.filter((c) => c.machineId === params.machineId)
+      }
+      return Promise.resolve(fixtures.wrapResponse(cameras))
+    }
+    if (url === '/admin/cameras/detail') {
+      const camera = fixtures.mockCameras.find((c) => c.id === params?.id)
+      if (camera) {
+        return Promise.resolve(fixtures.wrapResponse(camera))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('摄像头不存在', 404))
+    }
+    if (url === '/camera/list') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockCameras))
+    }
+    if (url.startsWith('/camera/') && !url.includes('/ptz')) {
+      const cameraId = url.split('/')[2]
+      const camera = fixtures.mockCameras.find((c) => c.cameraId === cameraId)
+      if (camera) {
+        return Promise.resolve(fixtures.wrapResponse(camera))
+      }
+    }
+    if (url === '/camera/current') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockChannels[0]))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+
+  request.post.mockImplementation((url: string, data?: any, config?: any) => {
+    if (url === '/admin/cameras/add') {
+      const newCamera = {
+        id: fixtures.mockCameras.length + 1,
+        ...data,
+        status: 'OFFLINE',
+        channels: [],
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString()
+      }
+      return Promise.resolve(fixtures.wrapResponse(newCamera))
+    }
+    if (url === '/admin/cameras/update') {
+      const camera = fixtures.mockCameras.find((c) => c.id === data?.id)
+      if (camera) {
+        return Promise.resolve(fixtures.wrapResponse({ ...camera, ...data }))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('摄像头不存在', 404))
+    }
+    if (url === '/admin/cameras/delete') {
+      return Promise.resolve(fixtures.wrapResponse(null))
+    }
+    if (url === '/admin/cameras/check') {
+      return Promise.resolve(fixtures.wrapResponse(true))
+    }
+    if (url === '/camera/switch') {
+      const channel = fixtures.mockChannels.find((c) => c.channelId === data?.channelId)
+      if (channel) {
+        return Promise.resolve(fixtures.wrapResponse(channel))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('通道不存在', 404))
+    }
+    if (url.includes('/ptz/start')) {
+      return Promise.resolve(fixtures.wrapResponse(null))
+    }
+    if (url.includes('/ptz/stop')) {
+      return Promise.resolve(fixtures.wrapResponse(null))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+}
+
+/**
+ * 设置统计 API mock
+ */
+export function mockStatsApi(request: MockedRequest) {
+  request.get.mockImplementation((url: string) => {
+    if (url === '/admin/stats/dashboard') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockDashboardStats))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+}
+
+/**
+ * 设置所有 API mock
+ */
+export function mockAllApis(request: MockedRequest) {
+  const originalGet = request.get.getMockImplementation?.() || (() => {})
+  const originalPost = request.post.getMockImplementation?.() || (() => {})
+
+  request.get.mockImplementation((url: string, params?: any) => {
+    // Auth
+    if (url === '/admin/auth/info') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockAdminInfo))
+    }
+    // Machines
+    if (url === '/admin/machines/list') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockMachines))
+    }
+    if (url === '/admin/machines/detail') {
+      const machine = fixtures.mockMachines.find((m) => m.id === params?.id)
+      return Promise.resolve(fixtures.wrapResponse(machine))
+    }
+    // Cameras
+    if (url === '/admin/cameras/list') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockCameras))
+    }
+    if (url === '/camera/list') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockCameras))
+    }
+    // Stats
+    if (url === '/admin/stats/dashboard') {
+      return Promise.resolve(fixtures.wrapResponse(fixtures.mockDashboardStats))
+    }
+    return Promise.resolve(fixtures.wrapErrorResponse('Not Found', 404))
+  })
+
+  request.post.mockImplementation((url: string, data?: any) => {
+    if (url === '/admin/auth/login') {
+      if (data?.username === 'admin' && data?.password === 'admin123') {
+        return Promise.resolve(fixtures.wrapResponse(fixtures.mockLoginResponse))
+      }
+      return Promise.resolve(fixtures.wrapErrorResponse('用户名或密码错误', 401))
+    }
+    if (url === '/admin/auth/logout') {
+      return Promise.resolve(fixtures.wrapResponse(null))
+    }
+    return Promise.resolve(fixtures.wrapResponse(null))
+  })
+}

+ 9 - 48
tests/unit/api/login.spec.ts

@@ -1,6 +1,7 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest'
 import { login, getInfo, logout, changePassword } from '@/api/login'
 import * as request from '@/utils/request'
+import { mockLoginResponse, mockAdminInfo, wrapResponse, wrapErrorResponse } from '../../fixtures'
 
 // Mock request module
 vi.mock('@/utils/request', () => ({
@@ -15,21 +16,7 @@ describe('Login API', () => {
 
   describe('login', () => {
     it('should call POST /admin/auth/login with credentials', async () => {
-      const mockResponse = {
-        code: 200,
-        message: 'success',
-        data: {
-          token: 'test-token',
-          tokenType: 'Bearer',
-          expiresIn: 3600,
-          admin: {
-            id: 1,
-            username: 'admin',
-            nickname: 'Admin',
-            role: 'admin'
-          }
-        }
-      }
+      const mockResponse = wrapResponse(mockLoginResponse)
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
       const result = await login({ username: 'admin', password: 'password123' })
@@ -39,15 +26,11 @@ describe('Login API', () => {
         password: 'password123'
       })
       expect(result.code).toBe(200)
-      expect(result.data.token).toBe('test-token')
+      expect(result.data.token).toBe(mockLoginResponse.token)
     })
 
     it('should handle login failure', async () => {
-      const mockResponse = {
-        code: 401,
-        message: '用户名或密码错误',
-        data: null
-      }
+      const mockResponse = wrapErrorResponse('用户名或密码错误', 401)
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
       const result = await login({ username: 'wrong', password: 'wrong' })
@@ -59,34 +42,20 @@ describe('Login API', () => {
 
   describe('getInfo', () => {
     it('should call GET /admin/auth/info', async () => {
-      const mockResponse = {
-        code: 200,
-        message: 'success',
-        data: {
-          id: 1,
-          username: 'admin',
-          nickname: 'Admin',
-          role: 'admin',
-          lastLoginAt: '2024-01-01T00:00:00Z'
-        }
-      }
+      const mockResponse = wrapResponse(mockAdminInfo)
       vi.mocked(request.get).mockResolvedValue(mockResponse)
 
       const result = await getInfo()
 
       expect(request.get).toHaveBeenCalledWith('/admin/auth/info')
       expect(result.code).toBe(200)
-      expect(result.data.username).toBe('admin')
+      expect(result.data.username).toBe(mockAdminInfo.username)
     })
   })
 
   describe('logout', () => {
     it('should call POST /admin/auth/logout', async () => {
-      const mockResponse = {
-        code: 200,
-        message: 'success',
-        data: null
-      }
+      const mockResponse = wrapResponse(null)
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
       const result = await logout()
@@ -98,11 +67,7 @@ describe('Login API', () => {
 
   describe('changePassword', () => {
     it('should call POST /admin/auth/password with password data', async () => {
-      const mockResponse = {
-        code: 200,
-        message: '密码修改成功',
-        data: null
-      }
+      const mockResponse = wrapResponse(null, 200, '密码修改成功')
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
       const result = await changePassword({
@@ -118,11 +83,7 @@ describe('Login API', () => {
     })
 
     it('should handle wrong old password', async () => {
-      const mockResponse = {
-        code: 400,
-        message: '原密码错误',
-        data: null
-      }
+      const mockResponse = wrapErrorResponse('原密码错误', 400)
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
       const result = await changePassword({

+ 33 - 82
tests/unit/api/machine.spec.ts

@@ -1,6 +1,7 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest'
 import { listMachines, getMachine, addMachine, updateMachine, deleteMachine } from '@/api/machine'
 import * as request from '@/utils/request'
+import { mockMachines, wrapResponse, wrapErrorResponse } from '../../fixtures'
 
 vi.mock('@/utils/request', () => ({
   get: vi.fn(),
@@ -14,113 +15,71 @@ describe('Machine API', () => {
 
   describe('listMachines', () => {
     it('should call GET /admin/machines/list', async () => {
-      const mockResponse = {
-        code: 200,
-        message: 'success',
-        data: [
-          {
-            id: 1,
-            machineId: 'machine-001',
-            name: '测试机器1',
-            location: '一楼',
-            description: '测试用',
-            enabled: true,
-            cameraCount: 2,
-            createdAt: '2024-01-01 00:00:00',
-            updatedAt: '2024-01-01 00:00:00'
-          }
-        ]
-      }
+      const mockResponse = wrapResponse(mockMachines)
       vi.mocked(request.get).mockResolvedValue(mockResponse)
 
       const result = await listMachines()
 
       expect(request.get).toHaveBeenCalledWith('/admin/machines/list')
       expect(result.code).toBe(200)
-      expect(result.data).toHaveLength(1)
-      expect(result.data[0].machineId).toBe('machine-001')
+      expect(result.data).toHaveLength(mockMachines.length)
+      expect(result.data[0].machineId).toBe(mockMachines[0].machineId)
     })
   })
 
   describe('getMachine', () => {
     it('should call GET /admin/machines/detail with id', async () => {
-      const mockResponse = {
-        code: 200,
-        message: 'success',
-        data: {
-          id: 1,
-          machineId: 'machine-001',
-          name: '测试机器1',
-          location: '一楼',
-          description: '测试用',
-          enabled: true,
-          cameraCount: 2
-        }
-      }
+      const machine = mockMachines[0]
+      const mockResponse = wrapResponse(machine)
       vi.mocked(request.get).mockResolvedValue(mockResponse)
 
-      const result = await getMachine(1)
+      const result = await getMachine(machine.id)
 
-      expect(request.get).toHaveBeenCalledWith('/admin/machines/detail', { id: 1 })
+      expect(request.get).toHaveBeenCalledWith('/admin/machines/detail', { id: machine.id })
       expect(result.code).toBe(200)
-      expect(result.data.id).toBe(1)
+      expect(result.data.id).toBe(machine.id)
     })
   })
 
   describe('addMachine', () => {
     it('should call POST /admin/machines/add with data', async () => {
-      const mockResponse = {
-        code: 200,
-        message: '新增成功',
-        data: {
-          id: 1,
-          machineId: 'machine-002',
-          name: '新机器',
-          location: '二楼',
-          description: '新增测试',
-          enabled: true,
-          cameraCount: 0
-        }
+      const newMachine = {
+        ...mockMachines[0],
+        id: 4,
+        machineId: 'machine-004',
+        name: '新机器',
+        cameraCount: 0
       }
+      const mockResponse = wrapResponse(newMachine, 200, '新增成功')
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
       const addData = {
-        machineId: 'machine-002',
-        name: '新机器',
-        location: '二楼',
-        description: '新增测试'
+        machineId: newMachine.machineId,
+        name: newMachine.name,
+        location: newMachine.location,
+        description: newMachine.description
       }
       const result = await addMachine(addData)
 
       expect(request.post).toHaveBeenCalledWith('/admin/machines/add', addData)
       expect(result.code).toBe(200)
-      expect(result.data.machineId).toBe('machine-002')
+      expect(result.data.machineId).toBe(newMachine.machineId)
     })
   })
 
   describe('updateMachine', () => {
     it('should call POST /admin/machines/update with data', async () => {
-      const mockResponse = {
-        code: 200,
-        message: '修改成功',
-        data: {
-          id: 1,
-          machineId: 'machine-001',
-          name: '更新后名称',
-          location: '三楼',
-          description: '已更新',
-          enabled: false,
-          cameraCount: 2
-        }
-      }
+      const machine = mockMachines[0]
+      const updatedMachine = { ...machine, name: '更新后名称', location: '三楼' }
+      const mockResponse = wrapResponse(updatedMachine, 200, '修改成功')
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
       const updateData = {
-        id: 1,
+        id: machine.id,
         name: '更新后名称',
         location: '三楼',
-        description: '已更新',
-        enabled: false
+        description: machine.description,
+        enabled: machine.enabled
       }
       const result = await updateMachine(updateData)
 
@@ -132,30 +91,22 @@ describe('Machine API', () => {
 
   describe('deleteMachine', () => {
     it('should call POST /admin/machines/delete with id', async () => {
-      const mockResponse = {
-        code: 200,
-        message: '删除成功',
-        data: null
-      }
+      const mockResponse = wrapResponse(null, 200, '删除成功')
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
-      const result = await deleteMachine(1)
+      const result = await deleteMachine(mockMachines[2].id) // Use machine with 0 cameras
 
       expect(request.post).toHaveBeenCalledWith('/admin/machines/delete', undefined, {
-        params: { id: 1 }
+        params: { id: mockMachines[2].id }
       })
       expect(result.code).toBe(200)
     })
 
-    it('should handle delete failure', async () => {
-      const mockResponse = {
-        code: 400,
-        message: '该机器下存在摄像头,无法删除',
-        data: null
-      }
+    it('should handle delete failure when machine has cameras', async () => {
+      const mockResponse = wrapErrorResponse('该机器下存在摄像头,无法删除', 400)
       vi.mocked(request.post).mockResolvedValue(mockResponse)
 
-      const result = await deleteMachine(1)
+      const result = await deleteMachine(mockMachines[0].id) // Machine with cameras
 
       expect(result.code).toBe(400)
       expect(result.message).toBe('该机器下存在摄像头,无法删除')

+ 310 - 0
tests/unit/components/VideoPlayer.spec.ts

@@ -0,0 +1,310 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+
+// Mock hls.js
+vi.mock('hls.js', () => ({
+  default: class MockHls {
+    static isSupported() {
+      return true
+    }
+    static Events = {
+      MANIFEST_PARSED: 'hlsManifestParsed',
+      ERROR: 'hlsError'
+    }
+    static ErrorTypes = {
+      NETWORK_ERROR: 'networkError',
+      MEDIA_ERROR: 'mediaError'
+    }
+    loadSource = vi.fn()
+    attachMedia = vi.fn()
+    on = vi.fn()
+    destroy = vi.fn()
+    startLoad = vi.fn()
+    recoverMediaError = vi.fn()
+  }
+}))
+
+describe('VideoPlayer', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  describe('Native Video Player', () => {
+    it('should render native video element by default', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(wrapper.find('video').exists()).toBe(true)
+      expect(wrapper.find('video').attributes('src')).toBe('https://example.com/video.mp4')
+    })
+
+    it('should pass controls prop to video element', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native',
+          controls: true
+        }
+      })
+
+      expect(wrapper.find('video').attributes('controls')).toBeDefined()
+    })
+
+    it('should pass autoplay prop to video element', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native',
+          autoplay: true
+        }
+      })
+
+      expect(wrapper.find('video').attributes('autoplay')).toBeDefined()
+    })
+
+    it('should pass muted prop to video element', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native',
+          muted: true
+        }
+      })
+
+      expect(wrapper.find('video').attributes('muted')).toBeDefined()
+    })
+
+    it('should pass loop prop to video element', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native',
+          loop: true
+        }
+      })
+
+      expect(wrapper.find('video').attributes('loop')).toBeDefined()
+    })
+
+    it('should pass poster prop to video element', () => {
+      const poster = 'https://example.com/poster.jpg'
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native',
+          poster
+        }
+      })
+
+      expect(wrapper.find('video').attributes('poster')).toBe(poster)
+    })
+  })
+
+  describe('Cloudflare Stream Player', () => {
+    it('should render iframe for cloudflare player type', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          videoId: 'test-video-id',
+          playerType: 'cloudflare'
+        }
+      })
+
+      expect(wrapper.find('iframe').exists()).toBe(true)
+    })
+
+    it('should generate correct iframe src', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          videoId: 'test-video-id',
+          playerType: 'cloudflare',
+          customerDomain: 'example.cloudflarestream.com'
+        }
+      })
+
+      const iframeSrc = wrapper.find('iframe').attributes('src')
+      expect(iframeSrc).toContain('https://example.cloudflarestream.com/test-video-id/iframe')
+    })
+
+    it('should include autoplay in iframe src when enabled', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          videoId: 'test-video-id',
+          playerType: 'cloudflare',
+          autoplay: true
+        }
+      })
+
+      const iframeSrc = wrapper.find('iframe').attributes('src')
+      expect(iframeSrc).toContain('autoplay=true')
+    })
+
+    it('should include muted in iframe src when enabled', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          videoId: 'test-video-id',
+          playerType: 'cloudflare',
+          muted: true
+        }
+      })
+
+      const iframeSrc = wrapper.find('iframe').attributes('src')
+      expect(iframeSrc).toContain('muted=true')
+    })
+
+    it('should include loop in iframe src when enabled', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          videoId: 'test-video-id',
+          playerType: 'cloudflare',
+          loop: true
+        }
+      })
+
+      const iframeSrc = wrapper.find('iframe').attributes('src')
+      expect(iframeSrc).toContain('loop=true')
+    })
+  })
+
+  describe('HLS Player', () => {
+    it('should render video element for HLS player type', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/stream.m3u8',
+          playerType: 'hls'
+        }
+      })
+
+      expect(wrapper.find('video').exists()).toBe(true)
+    })
+  })
+
+  describe('Default Props', () => {
+    it('should have default controls as true', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4'
+        }
+      })
+
+      expect(wrapper.find('video').attributes('controls')).toBeDefined()
+    })
+
+    it('should have default muted as true', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4'
+        }
+      })
+
+      expect(wrapper.find('video').attributes('muted')).toBeDefined()
+    })
+
+    it('should have default playerType as native', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4'
+        }
+      })
+
+      // Should render video element directly, not iframe
+      expect(wrapper.find('video').exists()).toBe(true)
+      expect(wrapper.find('iframe').exists()).toBe(false)
+    })
+  })
+
+  describe('Exposed Methods', () => {
+    it('should expose play method', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(typeof wrapper.vm.play).toBe('function')
+    })
+
+    it('should expose pause method', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(typeof wrapper.vm.pause).toBe('function')
+    })
+
+    it('should expose stop method', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(typeof wrapper.vm.stop).toBe('function')
+    })
+
+    it('should expose setVolume method', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(typeof wrapper.vm.setVolume).toBe('function')
+    })
+
+    it('should expose setMuted method', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(typeof wrapper.vm.setMuted).toBe('function')
+    })
+
+    it('should expose screenshot method', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(typeof wrapper.vm.screenshot).toBe('function')
+    })
+
+    it('should expose fullscreen method', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4',
+          playerType: 'native'
+        }
+      })
+
+      expect(typeof wrapper.vm.fullscreen).toBe('function')
+    })
+  })
+
+  describe('Wrapper Element', () => {
+    it('should have video-player-wrapper class', () => {
+      const wrapper = mount(VideoPlayer, {
+        props: {
+          src: 'https://example.com/video.mp4'
+        }
+      })
+
+      expect(wrapper.find('.video-player-wrapper').exists()).toBe(true)
+    })
+  })
+})

+ 17 - 46
tests/unit/store/user.spec.ts

@@ -3,6 +3,7 @@ import { setActivePinia, createPinia } from 'pinia'
 import { useUserStore } from '@/store/user'
 import * as loginApi from '@/api/login'
 import * as auth from '@/utils/auth'
+import { mockLoginResponse, mockAdminInfo, wrapResponse, wrapErrorResponse } from '../../fixtures'
 
 // Mock modules
 vi.mock('@/api/login', () => ({
@@ -27,38 +28,21 @@ describe('User Store', () => {
 
   describe('loginAction', () => {
     it('should login successfully and store token', async () => {
-      const mockResponse = {
-        code: 200,
-        message: 'success',
-        data: {
-          token: 'test-token',
-          refreshToken: 'refresh-token',
-          admin: {
-            id: 1,
-            username: 'admin',
-            nickname: 'Admin',
-            role: 'admin'
-          }
-        }
-      }
+      const mockResponse = wrapResponse(mockLoginResponse)
       vi.mocked(loginApi.login).mockResolvedValue(mockResponse)
 
       const store = useUserStore()
       const result = await store.loginAction({ username: 'admin', password: 'password' })
 
       expect(result.code).toBe(200)
-      expect(store.token).toBe('test-token')
-      expect(store.userInfo?.username).toBe('admin')
-      expect(auth.setToken).toHaveBeenCalledWith('test-token')
-      expect(auth.setRefreshToken).toHaveBeenCalledWith('refresh-token')
+      expect(store.token).toBe(mockLoginResponse.token)
+      expect(store.userInfo?.username).toBe(mockLoginResponse.admin.username)
+      expect(auth.setToken).toHaveBeenCalledWith(mockLoginResponse.token)
+      expect(auth.setRefreshToken).toHaveBeenCalledWith(mockLoginResponse.refreshToken)
     })
 
     it('should not store token on login failure', async () => {
-      const mockResponse = {
-        code: 401,
-        message: '用户名或密码错误',
-        data: null
-      }
+      const mockResponse = wrapErrorResponse('用户名或密码错误', 401)
       vi.mocked(loginApi.login).mockResolvedValue(mockResponse)
 
       const store = useUserStore()
@@ -72,39 +56,26 @@ describe('User Store', () => {
 
   describe('getUserInfo', () => {
     it('should fetch and store user info', async () => {
-      const mockResponse = {
-        code: 200,
-        message: 'success',
-        data: {
-          id: 1,
-          username: 'admin',
-          nickname: 'Admin',
-          role: 'admin'
-        }
-      }
+      const mockResponse = wrapResponse(mockAdminInfo)
       vi.mocked(loginApi.getInfo).mockResolvedValue(mockResponse)
 
       const store = useUserStore()
       const result = await store.getUserInfo()
 
       expect(result.code).toBe(200)
-      expect(store.userInfo?.username).toBe('admin')
-      expect(store.userInfo?.role).toBe('admin')
+      expect(store.userInfo?.username).toBe(mockAdminInfo.username)
+      expect(store.userInfo?.role).toBe(mockAdminInfo.role)
     })
   })
 
   describe('logoutAction', () => {
     it('should clear token and user info on logout', async () => {
-      vi.mocked(loginApi.logout).mockResolvedValue({
-        code: 200,
-        message: 'success',
-        data: null
-      })
+      vi.mocked(loginApi.logout).mockResolvedValue(wrapResponse(null))
 
       const store = useUserStore()
       // Set initial state
-      store.token = 'test-token'
-      store.userInfo = { id: 1, username: 'admin', nickname: 'Admin', role: 'admin' }
+      store.token = mockLoginResponse.token
+      store.userInfo = mockAdminInfo
 
       await store.logoutAction()
 
@@ -118,8 +89,8 @@ describe('User Store', () => {
       vi.mocked(loginApi.logout).mockRejectedValue(new Error('Network error'))
 
       const store = useUserStore()
-      store.token = 'test-token'
-      store.userInfo = { id: 1, username: 'admin', nickname: 'Admin', role: 'admin' }
+      store.token = mockLoginResponse.token
+      store.userInfo = mockAdminInfo
 
       // logoutAction uses try/finally without catch, so error will propagate
       // but finally block still clears the state
@@ -140,8 +111,8 @@ describe('User Store', () => {
   describe('resetToken', () => {
     it('should clear all auth state', () => {
       const store = useUserStore()
-      store.token = 'test-token'
-      store.userInfo = { id: 1, username: 'admin', nickname: 'Admin', role: 'admin' }
+      store.token = mockLoginResponse.token
+      store.userInfo = mockAdminInfo
 
       store.resetToken()
 

+ 105 - 0
tests/unit/utils/auth.spec.ts

@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import {
+  getToken,
+  setToken,
+  removeToken,
+  getRefreshToken,
+  setRefreshToken,
+  removeRefreshToken
+} from '@/utils/auth'
+
+describe('Auth Utils', () => {
+  const TOKEN_KEY = 'Admin-Token'
+  const REFRESH_TOKEN_KEY = 'Admin-Refresh-Token'
+  const mockToken = 'mock-jwt-token-12345'
+  const mockRefreshToken = 'mock-refresh-token-67890'
+
+  beforeEach(() => {
+    // Clear localStorage before each test
+    localStorage.clear()
+  })
+
+  afterEach(() => {
+    // Clean up after each test
+    localStorage.clear()
+  })
+
+  describe('Token Management', () => {
+    it('should set and get token', () => {
+      setToken(mockToken)
+      expect(getToken()).toBe(mockToken)
+      expect(localStorage.getItem(TOKEN_KEY)).toBe(mockToken)
+    })
+
+    it('should return null when token is not set', () => {
+      expect(getToken()).toBeNull()
+    })
+
+    it('should remove token', () => {
+      setToken(mockToken)
+      expect(getToken()).toBe(mockToken)
+
+      removeToken()
+      expect(getToken()).toBeNull()
+      expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
+    })
+
+    it('should overwrite existing token', () => {
+      setToken(mockToken)
+      const newToken = 'new-token-54321'
+      setToken(newToken)
+      expect(getToken()).toBe(newToken)
+    })
+  })
+
+  describe('Refresh Token Management', () => {
+    it('should set and get refresh token', () => {
+      setRefreshToken(mockRefreshToken)
+      expect(getRefreshToken()).toBe(mockRefreshToken)
+      expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBe(mockRefreshToken)
+    })
+
+    it('should return null when refresh token is not set', () => {
+      expect(getRefreshToken()).toBeNull()
+    })
+
+    it('should remove refresh token', () => {
+      setRefreshToken(mockRefreshToken)
+      expect(getRefreshToken()).toBe(mockRefreshToken)
+
+      removeRefreshToken()
+      expect(getRefreshToken()).toBeNull()
+      expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull()
+    })
+
+    it('should overwrite existing refresh token', () => {
+      setRefreshToken(mockRefreshToken)
+      const newRefreshToken = 'new-refresh-token-11111'
+      setRefreshToken(newRefreshToken)
+      expect(getRefreshToken()).toBe(newRefreshToken)
+    })
+  })
+
+  describe('Token Isolation', () => {
+    it('should keep tokens independent', () => {
+      setToken(mockToken)
+      setRefreshToken(mockRefreshToken)
+
+      expect(getToken()).toBe(mockToken)
+      expect(getRefreshToken()).toBe(mockRefreshToken)
+
+      removeToken()
+      expect(getToken()).toBeNull()
+      expect(getRefreshToken()).toBe(mockRefreshToken) // Should still exist
+    })
+
+    it('should remove refresh token without affecting access token', () => {
+      setToken(mockToken)
+      setRefreshToken(mockRefreshToken)
+
+      removeRefreshToken()
+      expect(getRefreshToken()).toBeNull()
+      expect(getToken()).toBe(mockToken) // Should still exist
+    })
+  })
+})