|
|
@@ -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);
|
|
|
+ }
|
|
|
+}
|