Explorar el Código

merge(S-cfc-12): 合入 task.parent.createTask 契约测试(7 用例全绿)

Xiaogang Liao hace 1 semana
padre
commit
2a1f72ff5f

+ 221 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/task/TaskControllerCreateTaskTest.java

@@ -0,0 +1,221 @@
+package com.etotem.cfc.controller.task;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.service.TaskService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 覆盖驱动(S-cfc-12 / 能力键 task.parent.createTask):
+ * POST /api/tasks/create 创建任务并下发(家长给孩子布置习惯任务)的契约级单测。
+ *
+ * 契约(对应 SC-P-10「创建任务+下发」、API_REFERENCE.md §4.3 任务、TaskController.createTask):
+ * - 正常路径:家长填 标题/成员(B)/积分/频率 → POST /api/tasks/create → 统一 Result<Long> code=200
+ *   data=任务ID;userId(JwtInterceptor 注入)与 DTO 原样转交 taskService.createTask
+ * - DTO 反序列化:title/description/category/memberId/points/duration/taskType/frequency/needReview/
+ *   executorType/dimensionCode 正确映射进 CreateTaskDTO
+ * - 多成员下发边界:body.memberIds=[B1,B2] 原样传 Service(Service 层为每个成员建独立任务实例)
+ * - 统一 @PostMapping:GET 同路径 → 405
+ * - userId 属性缺失(@RequestAttribute required=true):400(该属性由 JwtInterceptor 注入,缺失即未认证)
+ * - Service 拒绝边界:handler 不含 try/catch,业务校验异常(家庭不足2人/成员不属于本家庭/频率或参数非法等)
+ *   直接上抛,由生产 @RestControllerAdvice GlobalExceptionHandler 统一转 Result.error(400, message)
+ *   (与 TC-P-05-E「频率非法 400」口径一致;区别于 addMember 的控制器内 try/catch 契约)
+ *
+ * 角色/权限口径:createTask handler 无 @RequestAttribute("role"/"roles"),角色不在本层判定;
+ * 授权由 JwtInterceptor(有效 token 才注入真实 userId)与 Service 层家庭归属/执行者校验承担,
+ * 故本测试覆盖 userId 注入与传递正确性而非角色分支(teacher|admin 不适用本 handler)。
+ * 缺陷观察(不在此断言为绿灯,交编排侧决策):/api/tasks/create 未列入
+ * JwtInterceptor.LOGIN_REQUIRED_PATHS,匿名(无 token→userId=-1)可到达 handler 走业务 500,
+ * 与 SEC-20261031-01 已修复的"匿名走到 controller 而非安全 401"口径漂移同类。
+ *
+ * 测试隔离:MockMvc standalone + mock TaskService,无 Spring 上下文 / 无 DB / 无外部服务(微信等)。
+ */
+public class TaskControllerCreateTaskTest {
+
+    private static final String URL = "/api/tasks/create";
+
+    @Mock
+    private TaskService taskService;
+
+    private MockMvc mockMvc;
+    private TaskController controller;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new TaskController();
+        // createTask 仅依赖 taskService;其余 @Resource 字段本方法不触碰,保持 null 无碍
+        ReflectionTestUtils.setField(controller, "taskService", taskService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    private static final String CHILD_BODY = "{"
+            + "\"title\":\"每天读书30分钟\","
+            + "\"description\":\"培养阅读习惯\","
+            + "\"category\":\"学习类\","
+            + "\"memberId\":11,"
+            + "\"points\":10,"
+            + "\"duration\":30,"
+            + "\"taskType\":\"recurring\","
+            + "\"frequency\":\"daily\","
+            + "\"needReview\":1,"
+            + "\"executorType\":\"child\","
+            + "\"dimensionCode\":\"wisdom\""
+            + "}";
+
+    // ==================== 正常路径 ====================
+
+    @Test
+    public void createTask_success_returnsTaskId() throws Exception {
+        Long userId = 1L;
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class))).thenReturn(42L);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(CHILD_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.message").value("success"))
+                .andExpect(jsonPath("$.data").value(42));
+
+        verify(taskService).createTask(eq(userId), any(CreateTaskDTO.class));
+    }
+
+    // ==================== DTO 字段映射 ====================
+
+    @Test
+    public void createTask_dtoFieldsMappedToService() throws Exception {
+        Long userId = 1L;
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class))).thenReturn(42L);
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(CHILD_BODY)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk());
+
+        ArgumentCaptor<CreateTaskDTO> captor = ArgumentCaptor.forClass(CreateTaskDTO.class);
+        verify(taskService).createTask(eq(userId), captor.capture());
+        CreateTaskDTO dto = captor.getValue();
+        assertNotNull(dto);
+        assertEquals("每天读书30分钟", dto.getTitle());
+        assertEquals("培养阅读习惯", dto.getDescription());
+        assertEquals("学习类", dto.getCategory());
+        assertEquals(Long.valueOf(11L), dto.getMemberId());
+        assertEquals(Integer.valueOf(10), dto.getPoints());
+        assertEquals(Integer.valueOf(30), dto.getDuration());
+        assertEquals("recurring", dto.getTaskType());
+        assertEquals("daily", dto.getFrequency());
+        assertEquals(Integer.valueOf(1), dto.getNeedReview());
+        assertEquals("child", dto.getExecutorType());
+        assertEquals("wisdom", dto.getDimensionCode());
+    }
+
+    // ==================== 关键边界:多成员下发 ====================
+
+    @Test
+    public void createTask_multiMember_memberIdsForwarded() throws Exception {
+        Long userId = 1L;
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class))).thenReturn(100L);
+        String body = "{\"title\":\"早晚刷牙\",\"memberIds\":[11,12],\"points\":5,"
+                + "\"frequency\":\"daily\",\"executorType\":\"child\"}";
+
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(body)
+                        .requestAttr("userId", userId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").value(100));
+
+        ArgumentCaptor<CreateTaskDTO> captor = ArgumentCaptor.forClass(CreateTaskDTO.class);
+        verify(taskService).createTask(eq(userId), captor.capture());
+        assertEquals(Arrays.asList(11L, 12L), captor.getValue().getMemberIds());
+    }
+
+    // ==================== 传输层契约 ====================
+
+    @Test
+    public void createTask_onlyPostAllowed_getReturns405() throws Exception {
+        mockMvc.perform(get(URL))
+                .andExpect(status().isMethodNotAllowed());
+    }
+
+    @Test
+    public void createTask_missingUserIdAttr_rejected400() throws Exception {
+        // @RequestAttribute("userId") required=true:缺失时 Spring 抛 ServletRequestBindingException,
+        // 由 DefaultHandlerExceptionResolver 映射为 400(JwtInterceptor 负责注入该属性,缺失即未认证)。
+        mockMvc.perform(post(URL)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(CHILD_BODY))
+                .andExpect(status().isBadRequest());
+
+        verify(taskService, never()).createTask(any(), any());
+    }
+
+    // ==================== 关键边界:Service 业务拒绝(异常不吞) ====================
+
+    @Test
+    public void createTask_serviceRejects_illegalArgumentNotSwallowedByHandler() {
+        // 契约:handler 无 try/catch,Service 校验失败(家庭不足2人 / 成员不属于本家庭 / 频率参数非法等)
+        // 直接上抛,生产环境由 @RestControllerAdvice GlobalExceptionHandler 统一转 Result.error(400, message)。
+        Long userId = 1L;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("任务");
+        dto.setMemberId(11L);
+        when(taskService.createTask(eq(userId), any(CreateTaskDTO.class)))
+                .thenThrow(new IllegalArgumentException("该任务需分配给其他家庭成员,当前家庭不足2人"));
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> controller.createTask(userId, dto));
+        assertEquals("该任务需分配给其他家庭成员,当前家庭不足2人", ex.getMessage());
+
+        verify(taskService).createTask(eq(userId), any(CreateTaskDTO.class));
+    }
+
+    // ==================== 直接调用级:统一 Result<T> ====================
+
+    @Test
+    public void createTask_directCall_uniformResultShape() {
+        Long userId = 1L;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("每天读书30分钟");
+        dto.setMemberId(11L);
+        dto.setPoints(10);
+        dto.setFrequency("daily");
+        dto.setExecutorType("child");
+        when(taskService.createTask(userId, dto)).thenReturn(77L);
+
+        Result<Long> result = controller.createTask(userId, dto);
+
+        assertNotNull(result);
+        assertEquals(Integer.valueOf(200), result.getCode());
+        assertEquals("success", result.getMessage());
+        assertEquals(Long.valueOf(77L), result.getData());
+        verify(taskService).createTask(userId, dto);
+    }
+}