|
@@ -0,0 +1,324 @@
|
|
|
|
|
+package com.etotem.cfc.controller.task;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.dto.TaskReviewDTO;
|
|
|
|
|
+import com.etotem.cfc.entity.Task;
|
|
|
|
|
+import com.etotem.cfc.mapper.TaskMapper;
|
|
|
|
|
+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 java.util.Collections;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+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-13 / 能力键 task.parent.review):
|
|
|
|
|
+ * TaskController 任务审批闭环(SC-P-11「任务审批完成(通过/驳回)」)的契约级单测。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 契约(对应 SC-P-11、API_REFERENCE.md §4.3 任务、TaskController /review /pending-review /accept /reject):
|
|
|
|
|
+ * - 家长审批:B 完成提交(status=pending_review)→ POST /api/tasks/{id}/review
|
|
|
|
|
+ * body {approved:true|false, comment, aiSuggestion, overrideAi} → 统一 Result<Boolean> code=200
|
|
|
|
|
+ * (Service 语义:通过 → 发分并置 completed;驳回 → 置回 pending 可重做)
|
|
|
|
|
+ * - 待审批列表:POST /api/tasks/pending-review(userId 由 JwtInterceptor 注入,Service 校验仅 parent 可见)
|
|
|
|
|
+ * - 接受/拒绝任务:POST /api/tasks/accept、POST /api/tasks/reject/{id}(memberId 取 body 或 currentMemberId)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 角色/权限口径(适用时):
|
|
|
|
|
+ * - reviewTask 与 accept/reject handler 均无 @RequestAttribute("role"/"roles"),角色不在本层判定;
|
|
|
|
|
+ * 授权由 JwtInterceptor(有效 token 才注入 userId/currentMemberId)+ Service 层执行人/家庭校验承担。
|
|
|
|
|
+ * - getPendingReviewTasks 的「仅 parent 可见」在 Service 层(user.role==parent),见 TaskServiceReviewTest。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 缺陷观察(不修实现,交编排侧转 GREEN 决策):
|
|
|
|
|
+ * 1) POST /api/tasks/accept 的 HTTP 契约已断:handler 为 @PostMapping("/accept") + @PathVariable Long id,
|
|
|
|
|
+ * 路径模板无 {id} → 参数解析抛 MissingPathVariableException(MockMvc 观测 500),且前端
|
|
|
|
|
+ * (cfc-frontend/utils/api.js acceptTask)把 taskId 放在 body {taskId, memberId} 里,handler 从不读取 body.taskId。
|
|
|
|
|
+ * 本测试用 acceptTask_httpPath_missingPathVariable_pinsObserved500 钉住当前观测行为;
|
|
|
|
|
+ * 修复方向(编排侧定夺):改映射为 /accept/{id}(同时前端改 path 传 id),或 handler 改读 body.taskId。
|
|
|
|
|
+ * 2) listen:/api/tasks/{id}/review 无 userId/家庭归属校验(仅凭 taskId 即可审批),
|
|
|
|
|
+ * 横向越权风险由前端入口遮挡,Service 层亦未校验审批人与任务归属家庭 → 观察记录,不改实现。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 测试隔离:MockMvc standalone + mock TaskService / TaskMapper,无 Spring 上下文 / 无 DB / 无外部服务(微信等)。
|
|
|
|
|
+ */
|
|
|
|
|
+public class TaskControllerReviewTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String REVIEW_URL = "/api/tasks/{id}/review";
|
|
|
|
|
+ private static final String PENDING_REVIEW_URL = "/api/tasks/pending-review";
|
|
|
|
|
+ private static final String ACCEPT_URL = "/api/tasks/accept";
|
|
|
|
|
+ private static final String REJECT_URL = "/api/tasks/reject/{id}";
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private TaskService taskService;
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private TaskMapper taskMapper;
|
|
|
|
|
+
|
|
|
|
|
+ private MockMvc mockMvc;
|
|
|
|
|
+ private TaskController controller;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ public void setUp() {
|
|
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
|
|
+ controller = new TaskController();
|
|
|
|
|
+ // review/pending-review 仅依赖 taskService;accept/reject 的 handler 内经 checkMemberOnly 触碰 taskMapper
|
|
|
|
|
+ ReflectionTestUtils.setField(controller, "taskService", taskService);
|
|
|
|
|
+ ReflectionTestUtils.setField(controller, "taskMapper", taskMapper);
|
|
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static Task taskWithId(long id) {
|
|
|
|
|
+ Task t = new Task();
|
|
|
|
|
+ t.setId(id);
|
|
|
|
|
+ t.setMemberOnly(0); // 非会员任务,checkMemberOnly 不触碰 membershipService
|
|
|
|
|
+ return t;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== POST /api/tasks/{id}/review 审核任务(通过/驳回) ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void reviewTask_approve_http200_returnsTrue() throws Exception {
|
|
|
|
|
+ when(taskService.reviewTask(eq(1L), any(TaskReviewDTO.class))).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(REVIEW_URL, 1L)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"approved\":true,\"comment\":\"完成得很好\"}"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.message").value("success"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data").value(true));
|
|
|
|
|
+
|
|
|
|
|
+ verify(taskService).reviewTask(eq(1L), any(TaskReviewDTO.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void reviewTask_reject_approvedFalseForwarded() throws Exception {
|
|
|
|
|
+ when(taskService.reviewTask(eq(1L), any(TaskReviewDTO.class))).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(REVIEW_URL, 1L)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"approved\":false,\"comment\":\"照片模糊请重做\"}"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.data").value(true));
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<TaskReviewDTO> captor = ArgumentCaptor.forClass(TaskReviewDTO.class);
|
|
|
|
|
+ verify(taskService).reviewTask(eq(1L), captor.capture());
|
|
|
|
|
+ assertEquals(Boolean.FALSE, captor.getValue().getApproved());
|
|
|
|
|
+ assertEquals("照片模糊请重做", captor.getValue().getComment());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void reviewTask_dtoFieldsMapped() throws Exception {
|
|
|
|
|
+ when(taskService.reviewTask(eq(1L), any(TaskReviewDTO.class))).thenReturn(true);
|
|
|
|
|
+ String body = "{\"approved\":true,\"aiSuggestion\":\"拍照需清晰\",\"overrideAi\":true,"
|
|
|
|
|
+ + "\"comment\":\"表现不错\",\"result\":\"pass\"}";
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(REVIEW_URL, 1L)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content(body))
|
|
|
|
|
+ .andExpect(status().isOk());
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<TaskReviewDTO> captor = ArgumentCaptor.forClass(TaskReviewDTO.class);
|
|
|
|
|
+ verify(taskService).reviewTask(eq(1L), captor.capture());
|
|
|
|
|
+ TaskReviewDTO dto = captor.getValue();
|
|
|
|
|
+ assertNotNull(dto);
|
|
|
|
|
+ assertEquals(Boolean.TRUE, dto.getApproved());
|
|
|
|
|
+ assertEquals("拍照需清晰", dto.getAiSuggestion());
|
|
|
|
|
+ assertEquals(Boolean.TRUE, dto.getOverrideAi());
|
|
|
|
|
+ assertEquals("表现不错", dto.getComment());
|
|
|
|
|
+ assertEquals("pass", dto.getResult());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 传输层契约 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void reviewTask_onlyPostAllowed_getReturns405() throws Exception {
|
|
|
|
|
+ mockMvc.perform(get(REVIEW_URL, 1L))
|
|
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void reviewTask_missingBody_rejected400() throws Exception {
|
|
|
|
|
+ // @RequestBody 为空 → HttpMessageNotReadableException → 400(与 TC-P-06-E「参数非法统一 400」口径一致)
|
|
|
|
|
+ mockMvc.perform(post(REVIEW_URL, 1L)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON))
|
|
|
|
|
+ .andExpect(status().isBadRequest());
|
|
|
|
|
+
|
|
|
|
|
+ verify(taskService, never()).reviewTask(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== POST /api/tasks/pending-review 家长待审批列表 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void pendingReview_parent_success_returnsList() throws Exception {
|
|
|
|
|
+ Task t1 = taskWithId(1L);
|
|
|
|
|
+ Task t2 = taskWithId(2L);
|
|
|
|
|
+ when(taskService.getPendingReviewTasks(7L)).thenReturn(Arrays.asList(t1, t2));
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(PENDING_REVIEW_URL)
|
|
|
|
|
+ .requestAttr("userId", 7L))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[0].id").value(1))
|
|
|
|
|
+ .andExpect(jsonPath("$.data[1].id").value(2));
|
|
|
|
|
+
|
|
|
|
|
+ verify(taskService).getPendingReviewTasks(7L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void pendingReview_missingUserIdAttr_rejected400() throws Exception {
|
|
|
|
|
+ // userId 由 JwtInterceptor 注入;缺失即未认证(与创建任务口径一致)
|
|
|
|
|
+ mockMvc.perform(post(PENDING_REVIEW_URL))
|
|
|
|
|
+ .andExpect(status().isBadRequest());
|
|
|
|
|
+
|
|
|
|
|
+ verify(taskService, never()).getPendingReviewTasks(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== POST /api/tasks/accept 接受任务 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void acceptTask_httpPath_missingPathVariable_pinsObserved500_defect() throws Exception {
|
|
|
|
|
+ // 契约期望:POST /api/tasks/accept(body {taskId, memberId})→ 接受任务成功 code=200 data=true。
|
|
|
|
|
+ // 实际观测(钉住当前行为,不改实现):路径模板 /accept 无 {id},而 handler 声明 @PathVariable Long id
|
|
|
|
|
+ // → 参数解析阶段抛 MissingPathVariableException → 500;body.taskId 亦从未被 handler 读取。
|
|
|
|
|
+ // ——真实缺陷,交编排侧转 GREEN 决策(修复后本用例应翻转为 200 契约断言)。
|
|
|
|
|
+ when(taskService.acceptTask(any(), any())).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(ACCEPT_URL)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"taskId\":1,\"memberId\":11}"))
|
|
|
|
|
+ .andExpect(status().isInternalServerError());
|
|
|
|
|
+
|
|
|
|
|
+ verify(taskService, never()).acceptTask(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void acceptTask_directCall_memberIdFromBody_success() {
|
|
|
|
|
+ Long id = 1L;
|
|
|
|
|
+ Long memberId = 11L;
|
|
|
|
|
+ when(taskMapper.selectById(id)).thenReturn(taskWithId(id)); // checkMemberOnly 放行
|
|
|
|
|
+ when(taskService.acceptTask(id, memberId)).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ HashMap<String, Object> params = new HashMap<>();
|
|
|
|
|
+ params.put("memberId", memberId);
|
|
|
|
|
+ Result<Boolean> result = controller.acceptTask(id, null, params);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result);
|
|
|
|
|
+ assertEquals(Integer.valueOf(200), result.getCode());
|
|
|
|
|
+ assertEquals("success", result.getMessage());
|
|
|
|
|
+ assertEquals(Boolean.TRUE, result.getData());
|
|
|
|
|
+ verify(taskService).acceptTask(id, memberId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void acceptTask_directCall_currentMemberIdAttrFallback() {
|
|
|
|
|
+ Long id = 1L;
|
|
|
|
|
+ Long memberId = 11L;
|
|
|
|
|
+ when(taskMapper.selectById(id)).thenReturn(taskWithId(id));
|
|
|
|
|
+ when(taskService.acceptTask(id, memberId)).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ // body 无 memberId → 退化为 @RequestAttribute currentMemberId
|
|
|
|
|
+ Result<Boolean> result = controller.acceptTask(id, memberId, new HashMap<>());
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(Integer.valueOf(200), result.getCode());
|
|
|
|
|
+ verify(taskService).acceptTask(id, memberId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void acceptTask_directCall_missingMemberId_error500() {
|
|
|
|
|
+ // memberId 为空 → 统一 Result.error(code=500,Result.error 默认口径)→ 不触碰 Service
|
|
|
|
|
+ Result<Boolean> result = controller.acceptTask(1L, null, new HashMap<>());
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result);
|
|
|
|
|
+ assertEquals(Integer.valueOf(500), result.getCode());
|
|
|
|
|
+ assertEquals("memberId不能为空", result.getMessage());
|
|
|
|
|
+ verify(taskService, never()).acceptTask(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void acceptTask_onlyPostAllowed_getReturns405() throws Exception {
|
|
|
|
|
+ mockMvc.perform(get(ACCEPT_URL))
|
|
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== POST /api/tasks/reject/{id} 拒绝任务 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void rejectTask_http_success_returnsTrue() throws Exception {
|
|
|
|
|
+ when(taskService.rejectTask(1L, 11L)).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(post(REJECT_URL, 1L)
|
|
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
|
|
+ .content("{\"memberId\":11}"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
|
|
+ .andExpect(jsonPath("$.message").value("success"))
|
|
|
|
|
+ .andExpect(jsonPath("$.data").value(true));
|
|
|
|
|
+
|
|
|
|
|
+ verify(taskService).rejectTask(1L, 11L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void rejectTask_directCall_missingMemberId_error500() {
|
|
|
|
|
+ Result<Boolean> result = controller.rejectTask(1L, null, new HashMap<>());
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result);
|
|
|
|
|
+ assertEquals(Integer.valueOf(500), result.getCode());
|
|
|
|
|
+ assertEquals("memberId不能为空", result.getMessage());
|
|
|
|
|
+ verify(taskService, never()).rejectTask(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void rejectTask_onlyPostAllowed_getReturns405() throws Exception {
|
|
|
|
|
+ mockMvc.perform(get(REJECT_URL, 1L))
|
|
|
|
|
+ .andExpect(status().isMethodNotAllowed());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 直接调用级:统一 Result<T> ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void reviewTask_directCall_uniformResultShape() {
|
|
|
|
|
+ TaskReviewDTO dto = new TaskReviewDTO();
|
|
|
|
|
+ dto.setApproved(true);
|
|
|
|
|
+ when(taskService.reviewTask(1L, dto)).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ Result<Boolean> result = controller.reviewTask(1L, dto);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result);
|
|
|
|
|
+ assertEquals(Integer.valueOf(200), result.getCode());
|
|
|
|
|
+ assertEquals("success", result.getMessage());
|
|
|
|
|
+ assertEquals(Boolean.TRUE, result.getData());
|
|
|
|
|
+ verify(taskService).reviewTask(1L, dto);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ public void pendingReview_directCall_uniformResultShape() {
|
|
|
|
|
+ when(taskService.getPendingReviewTasks(7L)).thenReturn(Collections.emptyList());
|
|
|
|
|
+
|
|
|
|
|
+ Result<java.util.List<Task>> result = controller.getPendingReviewTasks(7L);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result);
|
|
|
|
|
+ assertEquals(Integer.valueOf(200), result.getCode());
|
|
|
|
|
+ assertEquals("success", result.getMessage());
|
|
|
|
|
+ assertNotNull(result.getData());
|
|
|
|
|
+ assertEquals(0, result.getData().size());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|