Browse Source
- GET /api/resources 全量扁平数组(含 catalog/menu/button) - POST /api/resources JSON body 新增/编辑 + 6 种层级约束校验 - 字段必填:menu 缺 route 拒绝、button 缺 perms/denyBehavior/apiUrl/status 拒绝 - 编辑锁 type(不可变)+ 编辑时 route 未传保留原值 - SecurityConfig DEFAULT_IGNORE_URLS 放行 /api/resources/** - TDD: 27 个单测覆盖正反例,105 tests 全绿master
5 changed files with 668 additions and 0 deletions
@ -0,0 +1,32 @@ |
|||
package com.crm.auth.controller; |
|||
|
|||
import com.crm.auth.domain.dto.ResourceNode; |
|||
import com.crm.auth.service.IResourceService; |
|||
import com.crm.base.domain.result.Result; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 统一权限资源树管理端接口(ADR-0011) |
|||
*/ |
|||
@RestController |
|||
@RequestMapping("/api/resources") |
|||
@RequiredArgsConstructor |
|||
public class ResourceController { |
|||
|
|||
private final IResourceService resourceService; |
|||
|
|||
/** 全量资源树(扁平数组,前端按 parentId 组树) */ |
|||
@GetMapping |
|||
public Result<List<ResourceNode>> listAll() { |
|||
return Result.success(resourceService.listAll()); |
|||
} |
|||
|
|||
/** 新增或编辑节点(含校验) */ |
|||
@PostMapping |
|||
public Result<ResourceNode> save(@RequestBody ResourceNode node) { |
|||
return Result.success(resourceService.save(node)); |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
package com.crm.auth.service; |
|||
|
|||
import com.crm.auth.domain.dto.ResourceNode; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 统一权限资源树服务(ADR-0011) |
|||
*/ |
|||
public interface IResourceService { |
|||
|
|||
/** 全量资源树扁平数组(所有节点,不按角色过滤) */ |
|||
List<ResourceNode> listAll(); |
|||
|
|||
/** 新增或编辑节点(含层级约束、字段必填、编辑锁 type) */ |
|||
ResourceNode save(ResourceNode node); |
|||
} |
|||
@ -0,0 +1,180 @@ |
|||
package com.crm.auth.service.impl; |
|||
|
|||
import cn.hutool.core.util.StrUtil; |
|||
import com.crm.auth.constant.AuthConstants; |
|||
import com.crm.auth.domain.dto.ResourceNode; |
|||
import com.crm.auth.domain.entity.SysMenu; |
|||
import com.crm.auth.domain.enums.MenuType; |
|||
import com.crm.auth.service.IResourceService; |
|||
import com.crm.auth.service.ISysMenuService; |
|||
import com.crm.base.domain.exception.BusinessErrorException; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Service; |
|||
|
|||
import java.util.List; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** |
|||
* 统一权限资源树服务实现(ADR-0011) |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class ResourceServiceImpl implements IResourceService { |
|||
|
|||
/** 资源节点参数非法 */ |
|||
private static final int CODE_RESOURCE_INVALID = 61009; |
|||
|
|||
private final ISysMenuService sysMenuService; |
|||
|
|||
@Override |
|||
public List<ResourceNode> listAll() { |
|||
return sysMenuService.list().stream() |
|||
.map(ResourceNode::fromEntity) |
|||
.collect(Collectors.toList()); |
|||
} |
|||
|
|||
@Override |
|||
public ResourceNode save(ResourceNode node) { |
|||
if (node == null) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "资源节点不能为空"); |
|||
} |
|||
if (StrUtil.isBlank(node.getName())) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "节点名称不能为空"); |
|||
} |
|||
if (node.getType() == null) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "节点类型不能为空"); |
|||
} |
|||
|
|||
SysMenu entity; |
|||
if (node.getId() != null) { |
|||
// 编辑模式
|
|||
entity = sysMenuService.getById(node.getId()); |
|||
if (entity == null) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "资源节点不存在:id=" + node.getId()); |
|||
} |
|||
// 编辑锁:type 不可变
|
|||
if (entity.getMenuType() != null && !entity.getMenuType().equals(node.getType().getCode())) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "节点类型不可变更(现有类型=" |
|||
+ MenuType.fromCode(entity.getMenuType()) + ")"); |
|||
} |
|||
} else { |
|||
entity = new SysMenu(); |
|||
} |
|||
|
|||
// 字段必填(按 type,编辑模式可复用已有值)
|
|||
validateRequiredFields(node, entity); |
|||
|
|||
// 层级约束
|
|||
validateHierarchy(node); |
|||
|
|||
// DTO → Entity 映射
|
|||
applyToEntity(node, entity); |
|||
|
|||
sysMenuService.saveOrUpdate(entity); |
|||
log.info("保存资源节点:id={}, name={}, type={}", entity.getId(), entity.getMenuName(), |
|||
MenuType.fromCode(entity.getMenuType())); |
|||
|
|||
return ResourceNode.fromEntity(entity); |
|||
} |
|||
|
|||
// ==================== 校验逻辑 ====================
|
|||
|
|||
/** 层级约束:6 种非法父子组合 */ |
|||
private void validateHierarchy(ResourceNode node) { |
|||
MenuType childType = node.getType(); |
|||
|
|||
if (node.getParentId() == null || node.getParentId() == 0) { |
|||
// 根节点:只允许 catalog / menu
|
|||
if (childType == MenuType.BUTTON) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "根节点不允许添加 button 类型"); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
SysMenu parent = sysMenuService.getById(node.getParentId()); |
|||
if (parent == null) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "父节点不存在:parentId=" + node.getParentId()); |
|||
} |
|||
MenuType parentType = MenuType.fromCode(parent.getMenuType()); |
|||
|
|||
// 叶子节点
|
|||
if (parentType == MenuType.BUTTON) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 是叶子节点,不可添加子节点"); |
|||
} |
|||
// catalog 只能挂 menu
|
|||
if (parentType == MenuType.CATALOG && childType != MenuType.MENU) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "catalog 节点下只能添加 menu 类型"); |
|||
} |
|||
// menu 只能挂 button
|
|||
if (parentType == MenuType.MENU && childType != MenuType.BUTTON) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "menu 节点下只能添加 button 类型"); |
|||
} |
|||
} |
|||
|
|||
/** 字段必填校验(编辑模式:若 route 未传但 DB 已有 path 则放行) */ |
|||
private void validateRequiredFields(ResourceNode node, SysMenu existing) { |
|||
MenuType type = node.getType(); |
|||
|
|||
if (type == MenuType.MENU) { |
|||
boolean hasRoute = StrUtil.isNotBlank(node.getRoute()); |
|||
boolean hasExistingPath = existing != null && StrUtil.isNotBlank(existing.getPath()); |
|||
if (!hasRoute && !hasExistingPath) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "menu 类型必须提供路由(route)"); |
|||
} |
|||
} |
|||
|
|||
if (type == MenuType.BUTTON) { |
|||
if (StrUtil.isBlank(node.getPerms())) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供权限码(perms)"); |
|||
} |
|||
if (StrUtil.isBlank(node.getDenyBehavior())) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供无权限行为(denyBehavior)"); |
|||
} |
|||
if (StrUtil.isBlank(node.getApiUrl())) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供接口 URL(apiUrl)"); |
|||
} |
|||
if (StrUtil.isBlank(node.getStatus())) { |
|||
throw new BusinessErrorException(CODE_RESOURCE_INVALID, "button 类型必须提供状态(status)"); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ==================== DTO → Entity ====================
|
|||
|
|||
/** 将 ResourceNode 中的业务字段写入 SysMenu(映射 name→menuName, route→path, type→menuType) */ |
|||
private void applyToEntity(ResourceNode node, SysMenu entity) { |
|||
entity.setMenuName(node.getName()); |
|||
entity.setParentId(node.getParentId() != null ? node.getParentId() : 0L); |
|||
|
|||
if (node.getType() != null) { |
|||
entity.setMenuType(node.getType().getCode()); |
|||
} |
|||
|
|||
// route / path:若未传则保留原有值
|
|||
if (node.getRoute() != null) { |
|||
entity.setPath(node.getRoute()); |
|||
} |
|||
|
|||
entity.setSort(node.getSort() != null ? node.getSort() : 0); |
|||
entity.setIcon(node.getIcon()); |
|||
entity.setVisible(true); // 默认可见
|
|||
|
|||
// button 专用字段(仅 button 类型时写入)
|
|||
if (node.getType() == MenuType.BUTTON) { |
|||
entity.setDescription(node.getDescription()); |
|||
entity.setPerms(node.getPerms()); |
|||
entity.setDenyBehavior(node.getDenyBehavior()); |
|||
entity.setStatus(node.getStatus()); |
|||
entity.setApiUrl(node.getApiUrl()); |
|||
} else { |
|||
// 非 button 节点清掉这些字段
|
|||
entity.setDescription(null); |
|||
entity.setPerms(null); |
|||
entity.setDenyBehavior(null); |
|||
entity.setStatus(null); |
|||
entity.setApiUrl(null); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,437 @@ |
|||
package com.crm.auth.service.impl; |
|||
|
|||
import com.crm.auth.domain.dto.ResourceNode; |
|||
import com.crm.auth.domain.entity.SysMenu; |
|||
import com.crm.auth.domain.enums.MenuType; |
|||
import com.crm.auth.service.ISysMenuService; |
|||
import com.crm.base.domain.exception.BusinessErrorException; |
|||
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.ArgumentCaptor; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
|
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
/** |
|||
* {@link ResourceServiceImpl} 单元测试:覆盖资源树查询、新增/编辑校验、层级约束、字段必填 |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
class ResourceServiceImplTest { |
|||
|
|||
@Mock |
|||
private ISysMenuService sysMenuService; |
|||
|
|||
@InjectMocks |
|||
private ResourceServiceImpl resourceService; |
|||
|
|||
// ========== 常用桩数据 ==========
|
|||
|
|||
private SysMenu catalog; |
|||
private SysMenu menu; |
|||
private SysMenu button; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
catalog = buildMenu(1L, 0L, "系统管理", 1); |
|||
menu = buildMenu(10L, 1L, "用户管理", 2); |
|||
menu.setPath("/system/user"); |
|||
|
|||
button = buildMenu(100L, 10L, "查询用户", 3); |
|||
button.setPerms("crm:user:list"); |
|||
button.setDenyBehavior("hide"); |
|||
button.setApiUrl("/api/system/users/page"); |
|||
button.setStatus("enabled"); |
|||
} |
|||
|
|||
// ==================== 查询 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("全量资源树查询 -> 返回扁平 ResourceNode 数组,含 catalog/menu/button") |
|||
void listAll_returnsFlatListWithAllTypes() { |
|||
when(sysMenuService.list()).thenReturn(List.of(catalog, menu, button)); |
|||
|
|||
List<ResourceNode> result = resourceService.listAll(); |
|||
|
|||
assertThat(result).hasSize(3); |
|||
assertThat(result.get(0).getType()).isEqualTo(MenuType.CATALOG); |
|||
assertThat(result.get(0).getName()).isEqualTo("系统管理"); |
|||
assertThat(result.get(1).getType()).isEqualTo(MenuType.MENU); |
|||
assertThat(result.get(1).getRoute()).isEqualTo("/system/user"); |
|||
assertThat(result.get(2).getType()).isEqualTo(MenuType.BUTTON); |
|||
assertThat(result.get(2).getPerms()).isEqualTo("crm:user:list"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("空库 -> 返回空数组不抛异常") |
|||
void listAll_emptyDb_returnsEmptyList() { |
|||
when(sysMenuService.list()).thenReturn(List.of()); |
|||
|
|||
List<ResourceNode> result = resourceService.listAll(); |
|||
|
|||
assertThat(result).isEmpty(); |
|||
} |
|||
|
|||
// ==================== 新增 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("新增 catalog 节点 -> 成功 save") |
|||
void save_newCatalog_success() { |
|||
ResourceNode node = newResourceNode(null, null, "新目录", MenuType.CATALOG); |
|||
|
|||
resourceService.save(node); |
|||
|
|||
ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class); |
|||
verify(sysMenuService).saveOrUpdate(captor.capture()); |
|||
SysMenu saved = captor.getValue(); |
|||
assertThat(saved.getId()).isNull(); |
|||
assertThat(saved.getMenuType()).isEqualTo(1); |
|||
assertThat(saved.getMenuName()).isEqualTo("新目录"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("新增 menu 挂在 catalog 下 -> 成功 save") |
|||
void save_newMenuUnderCatalog_success() { |
|||
when(sysMenuService.getById(1L)).thenReturn(catalog); |
|||
|
|||
ResourceNode node = newResourceNode(null, 1L, "新菜单", MenuType.MENU); |
|||
node.setRoute("/new/menu"); |
|||
|
|||
resourceService.save(node); |
|||
|
|||
verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("新增 button 挂在 menu 下 -> 成功 save") |
|||
void save_newButtonUnderMenu_success() { |
|||
when(sysMenuService.getById(10L)).thenReturn(menu); |
|||
|
|||
ResourceNode node = newButtonNode(null, 10L, "新增按钮", "crm:user:add", "hide", "/api/user/add", "enabled"); |
|||
|
|||
resourceService.save(node); |
|||
|
|||
verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); |
|||
} |
|||
|
|||
// ==================== 层级约束 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("catalog 下挂 button -> 拒绝(catalog 只能挂 menu)") |
|||
void save_buttonUnderCatalog_rejected() { |
|||
when(sysMenuService.getById(1L)).thenReturn(catalog); |
|||
|
|||
ResourceNode node = newButtonNode(null, 1L, "非法按钮", "crm:x:y", "hide", "/api/x", "enabled"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("catalog") |
|||
.hasMessageContaining("menu"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("menu 下挂 catalog -> 拒绝(menu 只能挂 button)") |
|||
void save_catalogUnderMenu_rejected() { |
|||
when(sysMenuService.getById(10L)).thenReturn(menu); |
|||
|
|||
ResourceNode node = newResourceNode(null, 10L, "非法目录", MenuType.CATALOG); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("menu") |
|||
.hasMessageContaining("button"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("menu 下挂 menu -> 拒绝(menu 只能挂 button)") |
|||
void save_menuUnderMenu_rejected() { |
|||
when(sysMenuService.getById(10L)).thenReturn(menu); |
|||
|
|||
ResourceNode node = newResourceNode(null, 10L, "非法子菜单", MenuType.MENU); |
|||
node.setRoute("/sub/menu"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("button 下挂子节点 -> 拒绝(button 是叶子)") |
|||
void save_childUnderButton_rejected() { |
|||
when(sysMenuService.getById(100L)).thenReturn(button); |
|||
|
|||
// 子 button 补全必填字段,让字段校验通过后再触发层级约束
|
|||
ResourceNode node = newButtonNode(null, 100L, "非法孙子", "crm:x:y", "hide", "/api/x", "enabled"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("叶子"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("根节点挂 button -> 拒绝(只允许 catalog/menu 作为根)") |
|||
void save_buttonAsRoot_rejected() { |
|||
ResourceNode node = newButtonNode(null, null, "根按钮", "crm:x:y", "hide", "/api/x", "enabled"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("根节点"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("根节点挂 catalog -> 成功") |
|||
void save_catalogAsRoot_success() { |
|||
ResourceNode node = newResourceNode(null, null, "根目录", MenuType.CATALOG); |
|||
|
|||
resourceService.save(node); |
|||
|
|||
verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("根节点挂 menu -> 成功") |
|||
void save_menuAsRoot_success() { |
|||
ResourceNode node = newResourceNode(null, null, "根菜单", MenuType.MENU); |
|||
node.setRoute("/root-menu"); |
|||
|
|||
resourceService.save(node); |
|||
|
|||
verify(sysMenuService).saveOrUpdate(any(SysMenu.class)); |
|||
} |
|||
|
|||
// ==================== 字段必填校验 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("menu 缺 route -> 拒绝") |
|||
void save_menuWithoutRoute_rejected() { |
|||
ResourceNode node = newResourceNode(null, null, "无路由菜单", MenuType.MENU); |
|||
// route 为 null
|
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("路由"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("menu route 为空字符串 -> 拒绝") |
|||
void save_menuWithBlankRoute_rejected() { |
|||
ResourceNode node = newResourceNode(null, null, "空路由菜单", MenuType.MENU); |
|||
node.setRoute(" "); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("路由"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("button 缺 perms -> 拒绝") |
|||
void save_buttonWithoutPerms_rejected() { |
|||
ResourceNode node = newButtonNode(null, null, "无perms", null, "hide", "/api/x", "enabled"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("perms"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("button perms 为空字符串 -> 拒绝") |
|||
void save_buttonWithBlankPerms_rejected() { |
|||
ResourceNode node = newButtonNode(null, null, "空perms", " ", "hide", "/api/x", "enabled"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("perms"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("button 缺 denyBehavior -> 拒绝") |
|||
void save_buttonWithoutDenyBehavior_rejected() { |
|||
ResourceNode node = newButtonNode(null, null, "无deny", "crm:x:y", null, "/api/x", "enabled"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("denyBehavior"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("button 缺 apiUrl -> 拒绝") |
|||
void save_buttonWithoutApiUrl_rejected() { |
|||
ResourceNode node = newButtonNode(null, null, "无api", "crm:x:y", "hide", null, "enabled"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("apiUrl"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("button 缺 status -> 拒绝") |
|||
void save_buttonWithoutStatus_rejected() { |
|||
ResourceNode node = newButtonNode(null, null, "无status", "crm:x:y", "hide", "/api/x", null); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("status"); |
|||
} |
|||
|
|||
// ==================== 编辑 type 锁 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("编辑时尝试改 type(catalog→menu)-> 拒绝") |
|||
void save_editChangeType_rejected() { |
|||
when(sysMenuService.getById(1L)).thenReturn(catalog); // type=1 (catalog)
|
|||
|
|||
ResourceNode node = newResourceNode(1L, null, "改类型", MenuType.MENU); |
|||
node.setRoute("/new-route"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("类型"); |
|||
verify(sysMenuService, never()).saveOrUpdate(any()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("编辑时 type 不变 -> 成功") |
|||
void save_editSameType_success() { |
|||
when(sysMenuService.getById(1L)).thenReturn(catalog); // type=1(catalog)
|
|||
|
|||
ResourceNode node = newResourceNode(1L, null, "改名字", MenuType.CATALOG); |
|||
|
|||
resourceService.save(node); |
|||
|
|||
ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class); |
|||
verify(sysMenuService).saveOrUpdate(captor.capture()); |
|||
assertThat(captor.getValue().getMenuName()).isEqualTo("改名字"); |
|||
} |
|||
|
|||
// ==================== 编辑时 type 保留原有节点 field ====================
|
|||
|
|||
@Test |
|||
@DisplayName("编辑 menu + path/component 未传入 -> 使用原有值") |
|||
void save_editMenuPreserveOldPath() { |
|||
when(sysMenuService.getById(10L)).thenReturn(menu); // path="/system/user"
|
|||
|
|||
ResourceNode node = newResourceNode(10L, null, "改名", MenuType.MENU); |
|||
// 不设 route——预期保留原有 path
|
|||
|
|||
resourceService.save(node); |
|||
|
|||
ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class); |
|||
verify(sysMenuService).saveOrUpdate(captor.capture()); |
|||
SysMenu saved = captor.getValue(); |
|||
assertThat(saved.getMenuName()).isEqualTo("改名"); |
|||
// route 未传入:保留原有 path
|
|||
assertThat(saved.getPath()).isEqualTo("/system/user"); |
|||
} |
|||
|
|||
// ==================== 编辑后 type 保持不变 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("编辑 catalog -> catalog 的 perms/apiUrl 等无用字段为 null") |
|||
void save_editCatalog_noButtonFields() { |
|||
when(sysMenuService.getById(1L)).thenReturn(catalog); |
|||
|
|||
ResourceNode node = newResourceNode(1L, null, "改昵称", MenuType.CATALOG); |
|||
node.setPerms("unused-perm"); // 前端可能误传,但 catalog 应该忽略
|
|||
|
|||
resourceService.save(node); |
|||
|
|||
ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class); |
|||
verify(sysMenuService).saveOrUpdate(captor.capture()); |
|||
SysMenu saved = captor.getValue(); |
|||
assertThat(saved.getMenuType()).isEqualTo(1); |
|||
assertThat(saved.getPerms()).isNull(); // catalog 不存 perms
|
|||
assertThat(saved.getApiUrl()).isNull(); // catalog 不存 apiUrl
|
|||
} |
|||
|
|||
// ==================== 非空校验 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("ResourceNode 为 null -> 拒绝") |
|||
void save_nullNode_rejected() { |
|||
assertThatThrownBy(() -> resourceService.save(null)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("不能为空"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("type 为 null -> 拒绝") |
|||
void save_nullType_rejected() { |
|||
ResourceNode node = new ResourceNode(); |
|||
node.setName("无类型"); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("类型"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("name 为空 -> 拒绝") |
|||
void save_blankName_rejected() { |
|||
ResourceNode node = newResourceNode(null, null, " ", MenuType.CATALOG); |
|||
|
|||
assertThatThrownBy(() -> resourceService.save(node)) |
|||
.isInstanceOf(BusinessErrorException.class) |
|||
.hasMessageContaining("名称"); |
|||
} |
|||
|
|||
// ==================== 编辑时传入 extra field 覆盖 ====================
|
|||
|
|||
@Test |
|||
@DisplayName("编辑 menu + 正常更新 route -> 覆盖原有 path") |
|||
void save_editMenuUpdateRoute() { |
|||
when(sysMenuService.getById(10L)).thenReturn(menu); // path="/system/user"
|
|||
|
|||
ResourceNode node = newResourceNode(10L, null, "改名", MenuType.MENU); |
|||
node.setRoute("/new/path"); |
|||
|
|||
resourceService.save(node); |
|||
|
|||
ArgumentCaptor<SysMenu> captor = ArgumentCaptor.forClass(SysMenu.class); |
|||
verify(sysMenuService).saveOrUpdate(captor.capture()); |
|||
assertThat(captor.getValue().getPath()).isEqualTo("/new/path"); |
|||
} |
|||
|
|||
// ==================== 辅助方法 ====================
|
|||
|
|||
private SysMenu buildMenu(Long id, Long parentId, String name, int menuType) { |
|||
SysMenu m = new SysMenu(); |
|||
m.setId(id); |
|||
m.setParentId(parentId); |
|||
m.setMenuName(name); |
|||
m.setMenuType(menuType); |
|||
return m; |
|||
} |
|||
|
|||
private ResourceNode newResourceNode(Long id, Long parentId, String name, MenuType type) { |
|||
ResourceNode node = new ResourceNode(); |
|||
node.setId(id); |
|||
node.setParentId(parentId); |
|||
node.setName(name); |
|||
node.setType(type); |
|||
node.setSort(0); |
|||
return node; |
|||
} |
|||
|
|||
private ResourceNode newButtonNode(Long id, Long parentId, String name, |
|||
String perms, String denyBehavior, |
|||
String apiUrl, String status) { |
|||
ResourceNode node = new ResourceNode(); |
|||
node.setId(id); |
|||
node.setParentId(parentId); |
|||
node.setName(name); |
|||
node.setType(MenuType.BUTTON); |
|||
node.setSort(0); |
|||
node.setPerms(perms); |
|||
node.setDenyBehavior(denyBehavior); |
|||
node.setApiUrl(apiUrl); |
|||
node.setStatus(status); |
|||
return node; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue