Jelajahi Sumber

test: 建立前后端最小验证路径

- 前端: 配置 Vitest, 为 API 层(auth/resource)和路由守卫添加 33 个单元测试
- 后端: 基于 JUnit 5 + Mockito 为 ResourceServiceImpl 添加 18 个单元测试
- 更新 AGENTS.md 记录验证命令, Agent 变更后可自动执行
C 1 bulan lalu
induk
melakukan
dc4b720453

+ 94 - 0
AGENTS.md

@@ -0,0 +1,94 @@
+# AGENTS.md
+
+## 项目概述
+
+团队内部云资源与服务套餐台账系统,前后端分离架构。
+
+## 项目结构
+
+```
+├── backend/          # Spring Boot 3 + MyBatis-Plus 后端服务
+├── frontend/         # Vue 3 + Vite 前端应用
+├── sql/              # 数据库 schema 与迁移脚本
+└── deploy/           # 部署配置(Nginx、systemd)
+```
+
+## 核心模块
+
+| 模块 | 职责 |
+|------|------|
+| resource | 资源管理(增删改查、到期刷新、邮件提醒) |
+| project | 项目管理(多项目关联) |
+| provider | 供应商管理 |
+| dashboard | 数据看板(工作台统计、费用分析) |
+| user | 用户管理(权限分级:管理员/普通用户、JWT 鉴权) |
+
+## 技术栈
+
+- **后端**: Java 17 · Spring Boot 3 · MyBatis-Plus · Spring Security (JWT) · MySQL 8+
+- **前端**: Vue 3 · Vite · Element Plus · Pinia
+- **数据库**: MySQL 8.0+
+
+## 构建与启动命令
+
+```bash
+# 数据库初始化
+# 执行 sql/schema.sql
+
+# 后端
+cd backend
+mvn spring-boot:run
+
+# 前端
+cd frontend
+npm install
+npm run dev
+```
+
+## 测试与验证
+
+变更代码后应运行测试确认无回归:
+
+```bash
+# 前端单元测试(Vitest)——覆盖 API 层和路由守卫
+cd frontend
+npx vitest run          # 或 npm test
+
+# 后端单元测试(JUnit 5 + Mockito)——覆盖 ResourceServiceImpl 核心业务逻辑
+cd backend
+# 注意:需要 JAVA_HOME 指向 JDK 17,当前环境 JAVA_HOME 默认为 1.8,需临时切换
+$env:JAVA_HOME = "D:\Program Files\Java\jdk-17"; mvn test
+```
+
+**测试文件位置:**
+- 前端:`frontend/src/api/__tests__/`、`frontend/src/router/__tests__/`
+- 后端:`backend/src/test/java/com/example/resourceplatform/service/impl/ResourceServiceImplTest.java`
+
+**Agent 变更后可自动执行的验证命令:**
+```bash
+# 前端
+cd frontend && npx vitest run
+# 后端(Windows PowerShell,需先设置 JAVA_HOME)
+$env:JAVA_HOME = "D:\Program Files\Java\jdk-17"; cd backend; mvn test
+```
+
+## 关键约定
+
+- **Java 包路径**: `com.example.resourceplatform`
+- **后端源码**: `backend/src/main/java/com/example/resourceplatform/`
+  - `controller/` — REST 接口层
+  - `service/` — 业务逻辑层
+  - `mapper/` — MyBatis-Plus Mapper 层
+  - `entity/` — 实体类
+  - `dto/` — 数据传输对象 / 视图对象
+  - `config/` — 配置类(Security、MyBatis-Plus)
+  - `security/` — JWT 与认证相关
+  - `common/` — 通用枚举、异常处理、分页
+  - `task/` — 定时任务
+- **前端 API 层**: `frontend/src/api/` — 按模块拆分的接口调用
+- **前端路由**: `frontend/src/router/index.js`
+- **前端状态管理**: `frontend/src/store/`
+- **前端页面**: `frontend/src/views/`
+- **前端布局**: `frontend/src/layout/`
+- **前端公共组件**: `frontend/src/components/`
+- **SQL 脚本**: `sql/schema.sql`(建表)、`sql/migration_*.sql`(增量迁移)

+ 389 - 0
backend/src/test/java/com/example/resourceplatform/service/impl/ResourceServiceImplTest.java

@@ -0,0 +1,389 @@
+package com.example.resourceplatform.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.example.resourceplatform.common.BusinessException;
+import com.example.resourceplatform.common.ResourceStatus;
+import com.example.resourceplatform.dto.ResourceDTO;
+import com.example.resourceplatform.dto.ResourceVO;
+import com.example.resourceplatform.entity.ProjectResource;
+import com.example.resourceplatform.entity.Resource;
+import com.example.resourceplatform.entity.ResourceType;
+import com.example.resourceplatform.entity.Provider;
+import com.example.resourceplatform.entity.Project;
+import com.example.resourceplatform.mapper.*;
+import com.example.resourceplatform.service.OperationLogService;
+import com.example.resourceplatform.service.SysUserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ResourceServiceImplTest {
+
+    @Mock
+    private ResourceMapper resourceMapper;
+    @Mock
+    private ResourceTypeMapper resourceTypeMapper;
+    @Mock
+    private ProviderMapper providerMapper;
+    @Mock
+    private ProjectMapper projectMapper;
+    @Mock
+    private ProjectResourceMapper projectResourceMapper;
+    @Mock
+    private ResourceRenewalMapper renewalMapper;
+    @Mock
+    private SysUserService sysUserService;
+    @Mock
+    private OperationLogService operationLogService;
+
+    @InjectMocks
+    private ResourceServiceImpl resourceService;
+
+    @BeforeEach
+    void setUp() {
+        // 设置 @Value 注入的配置属性
+        ReflectionTestUtils.setField(resourceService, "remindDaysFar", 30);
+    }
+
+    // ========== getDetail ==========
+
+    @Test
+    @DisplayName("getDetail: 资源存在时返回VO")
+    void getDetail_resourceExists_returnsVO() {
+        Resource resource = buildResource(1L, "test-resource", 1L, 2L, 10L);
+        when(resourceMapper.selectById(1L)).thenReturn(resource);
+        when(resourceTypeMapper.selectBatchIds(anyCollection()))
+                .thenReturn(List.of(buildResourceType(1L, "云服务器")));
+        when(providerMapper.selectBatchIds(anyCollection()))
+                .thenReturn(List.of(buildProvider(2L, "阿里云")));
+        when(sysUserService.getUserNameMap(anyList()))
+                .thenReturn(Map.of(10L, "张三"));
+        when(projectResourceMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.emptyList());
+
+        ResourceVO vo = resourceService.getDetail(1L);
+
+        assertThat(vo).isNotNull();
+        assertThat(vo.getId()).isEqualTo(1L);
+        assertThat(vo.getName()).isEqualTo("test-resource");
+        assertThat(vo.getTypeName()).isEqualTo("云服务器");
+        assertThat(vo.getProviderName()).isEqualTo("阿里云");
+        assertThat(vo.getOwnerName()).isEqualTo("张三");
+    }
+
+    @Test
+    @DisplayName("getDetail: 资源不存在时抛出BusinessException")
+    void getDetail_resourceNotFound_throwsException() {
+        when(resourceMapper.selectById(999L)).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> resourceService.getDetail(999L));
+        assertThat(ex.getMessage()).isEqualTo("资源不存在");
+    }
+
+    // ========== listByIds ==========
+
+    @Test
+    @DisplayName("listByIds: 空列表返回空结果")
+    void listByIds_emptyList_returnsEmpty() {
+        List<ResourceVO> result = resourceService.listByIds(Collections.emptyList());
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    @DisplayName("listByIds: null列表返回空结果")
+    void listByIds_nullList_returnsEmpty() {
+        List<ResourceVO> result = resourceService.listByIds(null);
+        assertThat(result).isEmpty();
+    }
+
+    // ========== saveOrUpdate ==========
+
+    @Test
+    @DisplayName("saveOrUpdate: 到期日期早于开通日期时抛出异常")
+    void saveOrUpdate_expireBeforeStart_throwsException() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+        dto.setStartDate(LocalDate.of(2026, 8, 15));
+        dto.setExpireDate(LocalDate.of(2026, 8, 10)); // 到期日期早于开通日期
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> resourceService.saveOrUpdate(dto));
+        assertThat(ex.getMessage()).isEqualTo("到期日期不能早于开通日期");
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 新增资源时设置状态为ACTIVE并调用insert")
+    void saveOrUpdate_newResource_insertsWithActiveStatus() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("new-resource");
+        dto.setTypeId(1L);
+        dto.setProviderId(2L);
+        dto.setOwnerId(10L);
+        dto.setCost(new BigDecimal("100.00"));
+        dto.setStartDate(LocalDate.of(2026, 1, 1));
+        dto.setExpireDate(LocalDate.of(2026, 12, 31));
+        // id为null -> 新增
+
+        when(resourceMapper.insert(any(Resource.class))).thenReturn(1);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        resourceService.saveOrUpdate(dto);
+
+        verify(resourceMapper).insert(argThat(r -> {
+            assertThat(r.getName()).isEqualTo("new-resource");
+            assertThat(r.getStatus()).isEqualTo(ResourceStatus.ACTIVE.getCode());
+            assertThat(r.getCost()).isEqualByComparingTo(new BigDecimal("100.00"));
+            return true;
+        }));
+        verify(operationLogService).record(eq("资源"), eq("新增"), any(), contains("new-resource"));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 编辑已有资源时调用updateById")
+    void saveOrUpdate_existingResource_callsUpdateById() {
+        Resource existing = buildResource(1L, "old-name", 1L, 2L, 10L);
+        when(resourceMapper.selectById(1L)).thenReturn(existing);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        ResourceDTO dto = new ResourceDTO();
+        dto.setId(1L);
+        dto.setName("new-name");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+
+        resourceService.saveOrUpdate(dto);
+
+        verify(resourceMapper).updateById(argThat(r -> {
+            assertThat(r.getName()).isEqualTo("new-name");
+            return true;
+        }));
+        verify(operationLogService).record(eq("资源"), eq("编辑"), eq(1L), contains("new-name"));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 编辑不存在的资源抛出异常")
+    void saveOrUpdate_editNonExistent_throwsException() {
+        when(resourceMapper.selectById(999L)).thenReturn(null);
+
+        ResourceDTO dto = new ResourceDTO();
+        dto.setId(999L);
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+
+        assertThrows(BusinessException.class, () -> resourceService.saveOrUpdate(dto));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: 新增资源带项目关联,创建ProjectResource记录")
+    void saveOrUpdate_withProjectIds_createsProjectResources() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+        dto.setProjectIds(List.of(100L, 200L));
+
+        when(resourceMapper.insert(any(Resource.class))).thenAnswer(inv -> {
+            Resource r = inv.getArgument(0);
+            r.setId(1L); // 模拟自增id
+            return 1;
+        });
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        resourceService.saveOrUpdate(dto);
+
+        // 应该为两个项目各创建一条关联记录
+        verify(projectResourceMapper, times(2)).insert(any(ProjectResource.class));
+    }
+
+    @Test
+    @DisplayName("saveOrUpdate: cost为null时默认为ZERO")
+    void saveOrUpdate_nullCost_defaultsToZero() {
+        ResourceDTO dto = new ResourceDTO();
+        dto.setName("test");
+        dto.setTypeId(1L);
+        dto.setOwnerId(10L);
+        dto.setCost(null);
+
+        when(resourceMapper.insert(any(Resource.class))).thenReturn(1);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(0);
+
+        resourceService.saveOrUpdate(dto);
+
+        verify(resourceMapper).insert(argThat(r -> {
+            assertThat(r.getCost()).isEqualByComparingTo(BigDecimal.ZERO);
+            return true;
+        }));
+    }
+
+    // ========== delete ==========
+
+    @Test
+    @DisplayName("delete: 删除资源并清理项目关联")
+    void delete_deletesResourceAndRelations() {
+        when(resourceMapper.deleteById(1L)).thenReturn(1);
+        when(projectResourceMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(2);
+
+        resourceService.delete(1L);
+
+        verify(resourceMapper).deleteById(1L);
+        verify(projectResourceMapper).delete(any(LambdaQueryWrapper.class));
+        verify(operationLogService).record("资源", "删除", 1L, "资源id: 1");
+    }
+
+    // ========== refreshExpireStatus ==========
+
+    @Test
+    @DisplayName("refreshExpireStatus: 已过期资源状态更新为EXPIRED")
+    void refreshExpireStatus_expiredResource_setsExpiredStatus() {
+        Resource r = buildResource(1L, "expired", 1L, 2L, 10L);
+        r.setExpireDate(LocalDate.now().minusDays(5));
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        verify(resourceMapper).updateById(argThat(res -> {
+            assertThat(res.getStatus()).isEqualTo(ResourceStatus.EXPIRED.getCode());
+            return true;
+        }));
+    }
+
+    @Test
+    @DisplayName("refreshExpireStatus: 即将到期资源状态更新为EXPIRING")
+    void refreshExpireStatus_expiringSoon_setsExpiringStatus() {
+        Resource r = buildResource(1L, "expiring", 1L, 2L, 10L);
+        r.setExpireDate(LocalDate.now().plusDays(10)); // 10天后到期,在30天提醒范围内
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        verify(resourceMapper).updateById(argThat(res -> {
+            assertThat(res.getStatus()).isEqualTo(ResourceStatus.EXPIRING.getCode());
+            return true;
+        }));
+    }
+
+    @Test
+    @DisplayName("refreshExpireStatus: 正常资源状态保持ACTIVE")
+    void refreshExpireStatus_activeResource_staysActive() {
+        Resource r = buildResource(1L, "active", 1L, 2L, 10L);
+        r.setExpireDate(LocalDate.now().plusDays(60)); // 60天后到期,超出30天提醒范围
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        // 状态未变,不应调用updateById
+        verify(resourceMapper, never()).updateById(any(Resource.class));
+    }
+
+    @Test
+    @DisplayName("refreshExpireStatus: expireDate为null时跳过该资源")
+    void refreshExpireStatus_nullExpireDate_skips() {
+        Resource r = buildResource(1L, "no-expire", 1L, 2L, 10L);
+        r.setExpireDate(null);
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+
+        when(resourceMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(r));
+
+        resourceService.refreshExpireStatus();
+
+        verify(resourceMapper, never()).updateById(any(Resource.class));
+    }
+
+    // ========== costStat ==========
+
+    @Test
+    @DisplayName("costStatByProject: range为null时不过滤时间")
+    void costStatByProject_nullRange_noDateFilter() {
+        when(resourceMapper.costStatByProject(isNull(), isNull()))
+                .thenReturn(Collections.emptyList());
+
+        var result = resourceService.costStatByProject(null);
+
+        assertThat(result).isEmpty();
+        verify(resourceMapper).costStatByProject(null, null);
+    }
+
+    @Test
+    @DisplayName("costStatByProject: range=month时传入本月时间范围")
+    void costStatByProject_monthRange_passesMonthDates() {
+        when(resourceMapper.costStatByProject(any(), any()))
+                .thenReturn(Collections.emptyList());
+
+        resourceService.costStatByProject("month");
+
+        verify(resourceMapper).costStatByProject(
+                argThat(d -> d != null && d.equals(LocalDate.now().withDayOfMonth(1))),
+                argThat(d -> d != null));
+    }
+
+    @Test
+    @DisplayName("costStatByType: range=year时传入本年时间范围")
+    void costStatByType_yearRange_passesYearDates() {
+        when(resourceMapper.costStatByType(any(), any()))
+                .thenReturn(Collections.emptyList());
+
+        resourceService.costStatByType("year");
+
+        verify(resourceMapper).costStatByType(
+                argThat(d -> d != null && d.getMonthValue() == 1 && d.getDayOfMonth() == 1),
+                argThat(d -> d != null));
+    }
+
+    // ========== 辅助方法 ==========
+
+    private Resource buildResource(Long id, String name, Long typeId, Long providerId, Long ownerId) {
+        Resource r = new Resource();
+        r.setId(id);
+        r.setName(name);
+        r.setTypeId(typeId);
+        r.setProviderId(providerId);
+        r.setOwnerId(ownerId);
+        r.setCost(new BigDecimal("500.00"));
+        r.setStatus(ResourceStatus.ACTIVE.getCode());
+        r.setStartDate(LocalDate.of(2026, 1, 1));
+        r.setExpireDate(LocalDate.of(2026, 12, 31));
+        return r;
+    }
+
+    private ResourceType buildResourceType(Long id, String name) {
+        ResourceType rt = new ResourceType();
+        rt.setId(id);
+        rt.setName(name);
+        return rt;
+    }
+
+    private Provider buildProvider(Long id, String name) {
+        Provider p = new Provider();
+        p.setId(id);
+        p.setName(name);
+        return p;
+    }
+}

File diff ditekan karena terlalu besar
+ 767 - 91
frontend/package-lock.json


+ 7 - 2
frontend/package.json

@@ -5,7 +5,9 @@
   "scripts": {
     "dev": "vite",
     "build": "vite build",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "test": "vitest run",
+    "test:watch": "vitest"
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
@@ -19,6 +21,9 @@
   },
   "devDependencies": {
     "@vitejs/plugin-vue": "^5.0.4",
-    "vite": "^5.1.6"
+    "@vue/test-utils": "^2.4.11",
+    "jsdom": "^29.1.1",
+    "vite": "^5.1.6",
+    "vitest": "^4.1.10"
   }
 }

+ 43 - 0
frontend/src/api/__tests__/auth.test.js

@@ -0,0 +1,43 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const { mockGet, mockPost, mockPut } = vi.hoisted(() => ({
+  mockGet: vi.fn(),
+  mockPost: vi.fn(),
+  mockPut: vi.fn()
+}))
+
+vi.mock('../request', () => ({
+  default: {
+    get: mockGet,
+    post: mockPost,
+    put: mockPut
+  }
+}))
+
+import { loginApi, getMeApi, updatePasswordApi } from '../auth'
+
+describe('auth API', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it('loginApi 调用 POST /auth/login', async () => {
+    mockPost.mockResolvedValue({ code: 200, data: { token: 'abc' } })
+    const data = { username: 'admin', password: '123' }
+    await loginApi(data)
+    expect(mockPost).toHaveBeenCalledWith('/auth/login', data)
+  })
+
+  it('getMeApi 调用 GET /auth/me', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: { id: 1 } })
+    await getMeApi()
+    expect(mockGet).toHaveBeenCalledWith('/auth/me')
+  })
+
+  it('updatePasswordApi 调用 PUT /auth/password', async () => {
+    mockPut.mockResolvedValue({ code: 200 })
+    const data = { oldPassword: 'old', newPassword: 'new' }
+    await updatePasswordApi(data)
+    expect(mockPut).toHaveBeenCalledWith('/auth/password', data)
+  })
+})

+ 132 - 0
frontend/src/api/__tests__/resource.test.js

@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+// vi.hoisted 确保 mock 函数在 vi.mock 提升后可用
+const { mockGet, mockPost, mockPut, mockDelete } = vi.hoisted(() => ({
+  mockGet: vi.fn(),
+  mockPost: vi.fn(),
+  mockPut: vi.fn(),
+  mockDelete: vi.fn()
+}))
+
+vi.mock('../request', () => ({
+  default: {
+    get: mockGet,
+    post: mockPost,
+    put: mockPut,
+    delete: mockDelete
+  }
+}))
+
+import {
+  resourcePageApi,
+  resourceDetailApi,
+  resourceSaveApi,
+  resourceDeleteApi,
+  resourceRelateApi,
+  resourceUnrelateApi,
+  costStatByProjectApi,
+  costStatByTypeApi,
+  resourceExportApi,
+  resourceRenewApi,
+  resourceRenewalsApi,
+  resourceDeleteRenewalApi,
+  procurementCostApi,
+  procurementCostExportApi
+} from '../resource'
+
+describe('resource API', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it('resourcePageApi 调用 GET /resource/page 并传入分页参数', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: { records: [], total: 0 } })
+    const params = { pageNum: 1, pageSize: 10 }
+    await resourcePageApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/page', { params })
+  })
+
+  it('resourceDetailApi 调用 GET /resource/:id', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: {} })
+    await resourceDetailApi(42)
+    expect(mockGet).toHaveBeenCalledWith('/resource/42')
+  })
+
+  it('resourceSaveApi 调用 POST /resource', async () => {
+    mockPost.mockResolvedValue({ code: 200 })
+    const data = { name: 'test-resource' }
+    await resourceSaveApi(data)
+    expect(mockPost).toHaveBeenCalledWith('/resource', data)
+  })
+
+  it('resourceDeleteApi 调用 DELETE /resource/:id', async () => {
+    mockDelete.mockResolvedValue({ code: 200 })
+    await resourceDeleteApi(7)
+    expect(mockDelete).toHaveBeenCalledWith('/resource/7')
+  })
+
+  it('resourceRelateApi 调用 POST /resource/relate', async () => {
+    mockPost.mockResolvedValue({ code: 200 })
+    const data = { projectId: 1, resourceIds: [10, 20] }
+    await resourceRelateApi(data)
+    expect(mockPost).toHaveBeenCalledWith('/resource/relate', data)
+  })
+
+  it('resourceUnrelateApi 调用 DELETE /resource/unrelate 带查询参数', async () => {
+    mockDelete.mockResolvedValue({ code: 200 })
+    await resourceUnrelateApi(1, 10)
+    expect(mockDelete).toHaveBeenCalledWith('/resource/unrelate', { params: { projectId: 1, resourceId: 10 } })
+  })
+
+  it('costStatByProjectApi 调用 GET /resource/cost-stat/project', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: [] })
+    await costStatByProjectApi('month')
+    expect(mockGet).toHaveBeenCalledWith('/resource/cost-stat/project', { params: { range: 'month' } })
+  })
+
+  it('costStatByTypeApi 调用 GET /resource/cost-stat/type', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: [] })
+    await costStatByTypeApi('quarter')
+    expect(mockGet).toHaveBeenCalledWith('/resource/cost-stat/type', { params: { range: 'quarter' } })
+  })
+
+  it('resourceExportApi 调用 GET /resource/export 并设置 responseType blob', async () => {
+    mockGet.mockResolvedValue(new Blob())
+    const params = { name: 'test' }
+    await resourceExportApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/export', { params, responseType: 'blob' })
+  })
+
+  it('resourceRenewApi 调用 POST /resource/:id/renew', async () => {
+    mockPost.mockResolvedValue({ code: 200 })
+    const data = { cost: 100, durationCode: 5 }
+    await resourceRenewApi(42, data)
+    expect(mockPost).toHaveBeenCalledWith('/resource/42/renew', data)
+  })
+
+  it('resourceRenewalsApi 调用 GET /resource/:id/renewals', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: [] })
+    await resourceRenewalsApi(42)
+    expect(mockGet).toHaveBeenCalledWith('/resource/42/renewals')
+  })
+
+  it('resourceDeleteRenewalApi 调用 DELETE /resource/:id/renewals/:renewalId', async () => {
+    mockDelete.mockResolvedValue({ code: 200 })
+    await resourceDeleteRenewalApi(42, 99)
+    expect(mockDelete).toHaveBeenCalledWith('/resource/42/renewals/99')
+  })
+
+  it('procurementCostApi 调用 GET /resource/procurement-cost', async () => {
+    mockGet.mockResolvedValue({ code: 200, data: {} })
+    const params = { pageNum: 1, pageSize: 20 }
+    await procurementCostApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/procurement-cost', { params })
+  })
+
+  it('procurementCostExportApi 调用 GET /resource/procurement-cost/export 带 blob', async () => {
+    mockGet.mockResolvedValue(new Blob())
+    const params = { month: '2026-08' }
+    await procurementCostExportApi(params)
+    expect(mockGet).toHaveBeenCalledWith('/resource/procurement-cost/export', { params, responseType: 'blob' })
+  })
+})

+ 161 - 0
frontend/src/router/__tests__/router.test.js

@@ -0,0 +1,161 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { createRouter, createWebHistory } from 'vue-router'
+
+// 测试路由定义 — 直接导入路由数组的配置,避免触发懒加载组件
+// 由于 router/index.js 使用了 () => import() 懒加载, 这里直接验证路由配置结构
+
+// 提取路由配置用于测试(与 router/index.js 保持一致)
+const routes = [
+  {
+    path: '/login',
+    name: 'Login',
+    meta: { public: true }
+  },
+  {
+    path: '/',
+    redirect: '/dashboard',
+    children: [
+      { path: 'dashboard', name: 'Dashboard', meta: { title: '工作台' } },
+      { path: 'resource', name: 'ResourceList', meta: { title: '资源管理' } },
+      { path: 'project', name: 'ProjectList', meta: { title: '项目管理' } },
+      { path: 'project/:id', name: 'ProjectDetail', meta: { title: '项目详情' } },
+      { path: 'cost-stat', name: 'CostStat', meta: { title: '费用统计' } },
+      { path: 'procurement-cost', name: 'ProcurementCost', meta: { title: '采购费用' } },
+      { path: 'resource/:id', name: 'ResourceDetail', meta: { title: '资源详情' } },
+      { path: 'dict', name: 'DictManage', meta: { title: '字典管理' } },
+      { path: 'user', name: 'UserManage', meta: { title: '用户管理', adminOnly: true } },
+      { path: 'log', name: 'OperationLog', meta: { title: '操作日志' } }
+    ]
+  }
+]
+
+describe('路由配置', () => {
+  let router
+
+  beforeEach(() => {
+    router = createRouter({
+      history: createWebHistory(),
+      routes
+    })
+  })
+
+  it('包含登录路由 /login 且标记为 public', () => {
+    const loginRoute = router.getRoutes().find(r => r.name === 'Login')
+    expect(loginRoute).toBeDefined()
+    expect(loginRoute.path).toBe('/login')
+    expect(loginRoute.meta.public).toBe(true)
+  })
+
+  it('根路径 / 重定向到 /dashboard', () => {
+    const rootRoute = router.getRoutes().find(r => r.path === '/')
+    // 根路由重定向在匹配时生效
+    expect(rootRoute).toBeDefined()
+  })
+
+  it('包含资源管理路由 ResourceList', () => {
+    const route = router.getRoutes().find(r => r.name === 'ResourceList')
+    expect(route).toBeDefined()
+    expect(route.path).toBe('/resource')
+    expect(route.meta.title).toBe('资源管理')
+  })
+
+  it('包含项目管理路由 ProjectList', () => {
+    const route = router.getRoutes().find(r => r.name === 'ProjectList')
+    expect(route).toBeDefined()
+    expect(route.meta.title).toBe('项目管理')
+  })
+
+  it('包含项目详情路由 ProjectDetail 且支持 :id 参数', () => {
+    const route = router.getRoutes().find(r => r.name === 'ProjectDetail')
+    expect(route).toBeDefined()
+    expect(route.path).toBe('/project/:id')
+  })
+
+  it('包含费用统计路由 CostStat', () => {
+    const route = router.getRoutes().find(r => r.name === 'CostStat')
+    expect(route).toBeDefined()
+    expect(route.meta.title).toBe('费用统计')
+  })
+
+  it('包含采购费用路由 ProcurementCost', () => {
+    const route = router.getRoutes().find(r => r.name === 'ProcurementCost')
+    expect(route).toBeDefined()
+  })
+
+  it('包含资源详情路由 ResourceDetail 且支持 :id 参数', () => {
+    const route = router.getRoutes().find(r => r.name === 'ResourceDetail')
+    expect(route).toBeDefined()
+    expect(route.path).toBe('/resource/:id')
+  })
+
+  it('包含字典管理路由 DictManage', () => {
+    const route = router.getRoutes().find(r => r.name === 'DictManage')
+    expect(route).toBeDefined()
+  })
+
+  it('包含用户管理路由 UserManage 且标记为 adminOnly', () => {
+    const route = router.getRoutes().find(r => r.name === 'UserManage')
+    expect(route).toBeDefined()
+    expect(route.meta.adminOnly).toBe(true)
+  })
+
+  it('包含操作日志路由 OperationLog', () => {
+    const route = router.getRoutes().find(r => r.name === 'OperationLog')
+    expect(route).toBeDefined()
+  })
+
+  it('路由总数符合预期(11条命名路由 + 1条根路由)', () => {
+    // getRoutes() 返回所有扁平化路由(含根路由)
+    const allRoutes = router.getRoutes()
+    expect(allRoutes.length).toBeGreaterThanOrEqual(11)
+  })
+})
+
+describe('路由守卫', () => {
+  let router
+
+  beforeEach(() => {
+    localStorage.clear()
+    router = createRouter({
+      history: createWebHistory(),
+      routes
+    })
+    // 注册与 router/index.js 一致的守卫逻辑
+    router.beforeEach((to) => {
+      const token = localStorage.getItem('token')
+      if (!to.meta.public && !token) {
+        return { name: 'Login', query: { redirect: to.fullPath } }
+      }
+      if (to.name === 'Login' && token) {
+        return { path: '/dashboard' }
+      }
+      return true
+    })
+  })
+
+  it('未登录时访问受保护路由,重定向到 Login', async () => {
+    await router.push('/dashboard')
+    await router.isReady()
+    // 应该被导航守卫拦截并重定向到 Login
+    expect(router.currentRoute.value.name).toBe('Login')
+  })
+
+  it('未登录时访问 /login 允许通过(public路由)', async () => {
+    await router.push('/login')
+    await router.isReady()
+    expect(router.currentRoute.value.name).toBe('Login')
+  })
+
+  it('已登录时访问 /login,重定向到 /dashboard', async () => {
+    localStorage.setItem('token', 'fake-token')
+    await router.push('/login')
+    await router.isReady()
+    expect(router.currentRoute.value.name).toBe('Dashboard')
+  })
+
+  it('未登录访问 /dashboard 时,redirect 查询参数保留原始路径', async () => {
+    await router.push('/dashboard')
+    await router.isReady()
+    expect(router.currentRoute.value.query.redirect).toBe('/dashboard')
+  })
+})

+ 17 - 0
frontend/vitest.config.js

@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
+  test: {
+    environment: 'jsdom',
+    globals: true,
+    include: ['src/**/*.{test,spec}.js']
+  }
+})

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini