Просмотр исходного кода

test(cfc-backend): S-cfc-11 family.parent.switchRole 契约级单测补齐(13 例全绿)

覆盖 POST /api/family/user/switch-role(FamilyUserController.switchRole + UserService.switchRole):
- 正常路径:parent→teacher、child持有parent切回家长
- 关键边界:未登录 userId null/-1/0→401、role 缺失、服务层空结果→业务错误
- 角色/权限:admin 当前角色禁止切换、未持有目标角色抛异常、非法目标角色拒绝
纯 JUnit5+Mockito 直接调用,不依赖 Spring 上下文/微信/外部服务,不改 src/main。
E2E Test Bot 1 неделя назад
Родитель
Сommit
0dce400d2f

+ 256 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/FamilyUserSwitchRoleTest.java

@@ -0,0 +1,256 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.family.FamilyUserController;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+/**
+ * S-cfc-11 / family.parent.switchRole 契约级单测(RED 阶段补覆盖)。
+ *
+ * <p>覆盖 POST /api/family/user/switch-role(FamilyUserController.switchRole):
+ * <ul>
+ *   <li>正常路径:家长拥有 teacher 角色时切换到 teacher;child 持有 parent 角色可切回家长;</li>
+ *   <li>关键边界:未登录(userId null / &le;0)→ 401;服务层返回 null → 业务错误;role 参数缺失;</li>
+ *   <li>角色/权限:admin 当前角色不可切换、未持有目标角色不可切换、非法目标角色拒绝。</li>
+ * </ul>
+ *
+ * <p>纯单测(JUnit5 + Mockito 直接调用),不依赖 Spring 上下文 / 微信 / 外部服务。
+ * 仅覆盖 cfc-backend 已有实现,不改 src/main。
+ *
+ * <p>注意:不使用 {@code @Nested}(surefire 2.22.2 对仅嵌套测试的类按精确类名
+ * {@code -Dtest=FQN} 过滤会报 "No tests were executed"),直接平铺便于
+ * {@code mvn -DskipTests=false -Dtest=FamilyUserSwitchRoleTest test} 精确执行。
+ */
+@DisplayName("family.parent.switchRole 契约(FamilyUserController.switchRole)")
+class FamilyUserSwitchRoleTest {
+
+    // ========================================================================
+    // Controller 契约层:直接调用 FamilyUserController.switchRole,mock UserService
+    // ========================================================================
+
+    private FamilyUserController controller;
+    private UserService userService;
+
+    // ========================================================================
+    // Service 角色/权限层:直接调用 UserService.switchRole,mock UserMapper
+    // ========================================================================
+
+    private UserService service;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // Controller 层
+        controller = new FamilyUserController();
+        userService = mock(UserService.class);
+        setField(controller, "userService", userService);
+        // Service 层
+        service = new UserService();
+        userMapper = mock(UserMapper.class);
+        setField(service, "userMapper", userMapper);
+    }
+
+    // ---------------------------------------------------------------------
+    // Controller:正常路径
+    // ---------------------------------------------------------------------
+
+    @Test
+    @DisplayName("成功切换:家长拥有 teacher 角色 → 返回目标角色")
+    void controller_成功切换_返回目标角色() {
+        when(userService.switchRole(1L, "teacher")).thenReturn("teacher");
+
+        Result<String> r = controller.switchRole(1L, body("teacher"));
+
+        assertEquals(200, r.getCode());
+        assertEquals("teacher", r.getData());
+        verify(userService).switchRole(1L, "teacher");
+    }
+
+    // ---------------------------------------------------------------------
+    // Controller:关键边界(未登录 / role 缺失 / 服务层空结果)
+    // ---------------------------------------------------------------------
+
+    @Test
+    @DisplayName("未登录(userId=null)→ 401,不触达服务层")
+    void controller_未登录_userId为null_返回401() {
+        Result<String> r = controller.switchRole(null, body("teacher"));
+
+        assertEquals(401, r.getCode());
+        assertEquals("请先登录", r.getMessage());
+        verify(userService, never()).switchRole(any(), any());
+    }
+
+    @Test
+    @DisplayName("未登录(userId=-1 / 0 匿名)→ 401,不触达服务层")
+    void controller_未登录_userId非正_返回401() {
+        Result<String> r1 = controller.switchRole(-1L, body("teacher"));
+        assertEquals(401, r1.getCode());
+        assertEquals("请先登录", r1.getMessage());
+
+        Result<String> r2 = controller.switchRole(0L, body("teacher"));
+        assertEquals(401, r2.getCode());
+
+        verify(userService, never()).switchRole(any(), any());
+    }
+
+    @Test
+    @DisplayName("role 参数缺失 → 透传服务层空结果,提示切换失败")
+    void controller_缺失role参数_提示切换失败() {
+        Result<String> r = controller.switchRole(1L, Collections.emptyMap());
+
+        assertEquals(500, r.getCode());
+        assertEquals("切换失败,请确认您拥有该角色", r.getMessage());
+        verify(userService).switchRole(1L, null);
+    }
+
+    @Test
+    @DisplayName("服务层返回 null(非法角色/用户不存在)→ 业务错误提示")
+    void controller_服务返回null_提示切换失败() {
+        when(userService.switchRole(1L, "hacker")).thenReturn(null);
+
+        Result<String> r = controller.switchRole(1L, body("hacker"));
+
+        assertEquals(500, r.getCode());
+        assertEquals("切换失败,请确认您拥有该角色", r.getMessage());
+    }
+
+    // ---------------------------------------------------------------------
+    // Controller:角色/权限异常传播(不得吞成 200)
+    // ---------------------------------------------------------------------
+
+    @Test
+    @DisplayName("越权:目标角色不属于用户 → RuntimeException 向上传播,不得吞成 200")
+    void controller_无目标角色权限_异常传播() {
+        when(userService.switchRole(1L, "teacher"))
+                .thenThrow(new RuntimeException("您没有该角色权限"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> controller.switchRole(1L, body("teacher")));
+        assertEquals("您没有该角色权限", ex.getMessage());
+    }
+
+    @Test
+    @DisplayName("越权:当前角色为 admin → RuntimeException 向上传播,不得吞成 200")
+    void controller_管理员不允许切换_异常传播() {
+        when(userService.switchRole(1L, "parent"))
+                .thenThrow(new RuntimeException("管理员角色不允许切换"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> controller.switchRole(1L, body("parent")));
+        assertEquals("管理员角色不允许切换", ex.getMessage());
+    }
+
+    // ---------------------------------------------------------------------
+    // Service:正常路径 / 角色权限
+    // ---------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常路径:家长持有 teacher → 更新 role 并返回目标角色")
+    void service_拥有目标角色_更新并返回() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "parent", "parent", "teacher"));
+
+        String out = service.switchRole(1L, "teacher");
+
+        assertEquals("teacher", out);
+        verify(userMapper).updateById(argThat(u -> "teacher".equals(u.getRole())));
+    }
+
+    @Test
+    @DisplayName("正常路径:child 持有 parent 角色时切换回家长(角色白名单内)")
+    void service_child持有parent角色_可切回家长() {
+        when(userMapper.selectById(2L)).thenReturn(user(2L, "child", "parent", "child"));
+
+        String out = service.switchRole(2L, "parent");
+
+        assertEquals("parent", out);
+        verify(userMapper).updateById(argThat(u -> "parent".equals(u.getRole())));
+    }
+
+    // ---------------------------------------------------------------------
+    // Service:关键边界 / 越权拒绝
+    // ---------------------------------------------------------------------
+
+    @Test
+    @DisplayName("用户不存在 → 返回 null,不做任何更新")
+    void service_用户不存在_返回null() {
+        when(userMapper.selectById(99L)).thenReturn(null);
+
+        String out = service.switchRole(99L, "teacher");
+
+        assertNull(out);
+        verify(userMapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("当前角色为 admin → 抛异常,不做任何更新")
+    void service_当前角色为admin_禁止切换() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "admin", "admin"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.switchRole(1L, "parent"));
+        assertEquals("管理员角色不允许切换", ex.getMessage());
+        verify(userMapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("未持有目标角色(roles 无 teacher)→ 抛异常,不做任何更新")
+    void service_未拥有目标角色_抛异常() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "parent", "parent"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> service.switchRole(1L, "teacher"));
+        assertEquals("您没有该角色权限", ex.getMessage());
+        verify(userMapper, never()).updateById(any());
+    }
+
+    @Test
+    @DisplayName("边界:非法目标角色(不在 parent/child/后台角色白名单)→ 返回 null,不做更新")
+    void service_非法目标角色_返回null() {
+        when(userMapper.selectById(1L)).thenReturn(user(1L, "parent", "hacker"));
+
+        String out = service.switchRole(1L, "hacker");
+
+        assertNull(out);
+        verify(userMapper, never()).updateById(any());
+    }
+
+    // ========================================================================
+    // 工具
+    // ========================================================================
+
+    private Map<String, String> body(String role) {
+        Map<String, String> b = new HashMap<>();
+        b.put("role", role);
+        return b;
+    }
+
+    private User user(Long id, String role, String... ownedRoles) {
+        User u = new User();
+        u.setId(id);
+        u.setRole(role);
+        u.setRoles(ownedRoles == null ? null : Arrays.asList(ownedRoles));
+        return u;
+    }
+
+    /** 反射注入私有字段(贴合既有 unit 测试风格,如 GuidePlanReviewControllerAuthTest) */
+    private static void setField(Object target, String name, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+}