Explorar el Código

test(S-cfc-15): task.parent.review 判定优先级/缺省驳回边界契约(GREEN 收口 7 例全绿)

能力键 task.parent.review:在 S-cfc-13 已落地 32 例(TaskControllerReviewTest 17 +
TaskServiceReviewTest 15)全绿基础上,补齐 reviewTask 判定「优先级/缺省驳回」三处
未显式覆盖边界 + 控制器透传契约,实现已存在零新增,不改 src/main。

新增测试(7 例全绿,-DskipTests=false):
- TaskReviewDecisionBoundaryServiceTest(3 例,服务层判定语义,TaskService.reviewTask):
  1) approved=null && result=null → 缺省驳回(else 兜底:status→pending、completedAt 清空、不发分)
  2) approved=false + result=pass → approved 优先,仍驳回
  3) approved=true  + result=reject → approved 优先,仍通过(发分 PointsLog 10/earn + 余额100→110 + completed)
- TaskReviewDecisionBoundaryTest(4 例,控制器层 MockMvc/直接调用透传,统一 Result<Boolean>):
  result=pass-only 透传、approved=false+result=pass 双字段透传、approved=true+result=reject 双字段透传、
  空 DTO 直接调用 Result 形状(DTO 原样送达 Service,判定不在控制器层)

暴露缺陷观察(不修实现,同 S-cfc-13 口径,交编排侧转 GREEN 决策):
- POST /api/tasks/accept 路径契约断(handler @PostMapping("/accept")+@PathVariable Long id,
  路径模板无 {id} → MissingPathVariableException 500;前端把 taskId 放 body,handler 从不读取)——
  S-cfc-13 已以 acceptTask_httpPath_missingPathVariable_pinsObserved500_defect 钉住观测 500。
- /api/tasks/{id}/review 无 userId/家庭归属校验(仅凭 taskId 审批,横向越权风险由前端入口遮挡)。

溯源:SC-P-11 · tests/test-cases/parent.md TC-P-06/TC-P-06-E · API_REFERENCE.md §4.3 · TaskService.java:712
验证:cfc-backend mvn -DskipTests=false test -Dtest='TaskControllerReviewTest,TaskServiceReviewTest,
TaskReviewDecisionBoundaryTest,TaskReviewDecisionBoundaryServiceTest' → 39 例全绿 BUILD SUCCESS
Sisyphus hace 1 semana
padre
commit
bd5e41b74b

+ 141 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/task/TaskReviewDecisionBoundaryTest.java

@@ -0,0 +1,141 @@
+package com.etotem.cfc.controller.task;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.TaskReviewDTO;
+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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+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-15 / 能力键 task.parent.review · GREEN 收口):
+ * 控制器层(TaskController.reviewTask)在判定缺省/优先级场景下的契约透传。
+ *
+ * S-cfc-13 已落地 TaskControllerReviewTest(MockMvc + 直接调用,17 例全绿):通过/驳回/字段映射/405/400。
+ * 本类补齐四例「判定缺省/优先级」场景的控制器透传契约——判定语义在 Service 层(见
+ * TaskReviewDecisionBoundaryServiceTest),控制器只负责 DTO 透传与统一 Result&lt;Boolean&gt;:
+ *   1. 仅 result="pass"(无 approved)→ DTO 原样透传(service 判定通过);
+ *   2. approved=false + result="pass" → 两字段原样透传,判定交给 service(approved 优先 → 驳回);
+ *   3. approved=true  + result="reject" → 两字段原样透传,判定交给 service(approved 优先 → 通过);
+ *   4. approved=null + result=null(空 body 对象)→ DTO 透传(service 缺省驳回)。
+ *
+ * 测试隔离:@Mock TaskService + @Mock TaskMapper + MockMvc standalone(同 TaskControllerReviewTest),
+ * 无 Spring 上下文 / 无 DB / 无外部服务。
+ *
+ * 角色/权限口径(同 S-cfc-13):reviewTask 无 @RequestAttribute("role"/"roles"),角色不在本层判定;
+ * 「仅 parent 可见待审列表」在 Service 层校验(S-cfc-13 已覆盖)。
+ *
+ * 暴露缺陷观察(不修实现,交编排侧转 GREEN 决策):与 S-cfc-13 一致——/api/tasks/accept 路径契约断
+ * (模板无 {id} → 500,S-cfc-13 已钉住);/api/tasks/{id}/review 无 userId/家庭归属校验(横向越权风险)。
+ *
+ * 溯源:SC-P-11 · tests/test-cases/parent.md TC-P-06/TC-P-06-E · API_REFERENCE.md §4.3
+ */
+class TaskReviewDecisionBoundaryTest {
+
+    private static final String REVIEW_URL = "/api/tasks/{id}/review";
+
+    @Mock
+    private TaskService taskService;
+
+    @Mock
+    private TaskMapper taskMapper;
+
+    private MockMvc mockMvc;
+    private TaskController controller;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        controller = new TaskController();
+        ReflectionTestUtils.setField(controller, "taskService", taskService);
+        ReflectionTestUtils.setField(controller, "taskMapper", taskMapper);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    private void stubServiceTrue() {
+        when(taskService.reviewTask(eq(1L), any(TaskReviewDTO.class))).thenReturn(true);
+    }
+
+    @Test
+    void reviewTask_http_resultPassOnly_forwardedUnchanged() throws Exception {
+        stubServiceTrue();
+
+        mockMvc.perform(post(REVIEW_URL, 1L)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"result\":\"pass\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").value(true));
+
+        // 控制器透传:approved 保持 null,result="pass" 原样送达 service
+        ArgumentCaptor<TaskReviewDTO> captor = ArgumentCaptor.forClass(TaskReviewDTO.class);
+        verify(taskService).reviewTask(eq(1L), captor.capture());
+        assertEquals(null, captor.getValue().getApproved());
+        assertEquals("pass", captor.getValue().getResult());
+    }
+
+    @Test
+    void reviewTask_http_approvedFalse_resultPass_bothForwarded() throws Exception {
+        stubServiceTrue();
+
+        mockMvc.perform(post(REVIEW_URL, 1L)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"approved\":false,\"result\":\"pass\",\"comment\":\"已按新标准复核\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data").value(true));
+
+        // 控制器不做判定,两字段原样透传(判定优先级在 Service,见 TaskReviewDecisionBoundaryServiceTest)
+        ArgumentCaptor<TaskReviewDTO> captor = ArgumentCaptor.forClass(TaskReviewDTO.class);
+        verify(taskService).reviewTask(eq(1L), captor.capture());
+        assertEquals(Boolean.FALSE, captor.getValue().getApproved());
+        assertEquals("pass", captor.getValue().getResult());
+        assertEquals("已按新标准复核", captor.getValue().getComment());
+    }
+
+    @Test
+    void reviewTask_http_approvedTrue_resultReject_bothForwarded() throws Exception {
+        stubServiceTrue();
+
+        mockMvc.perform(post(REVIEW_URL, 1L)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"approved\":true,\"result\":\"reject\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200));
+
+        ArgumentCaptor<TaskReviewDTO> captor = ArgumentCaptor.forClass(TaskReviewDTO.class);
+        verify(taskService).reviewTask(eq(1L), captor.capture());
+        assertEquals(Boolean.TRUE, captor.getValue().getApproved());
+        assertEquals("reject", captor.getValue().getResult());
+    }
+
+    @Test
+    void reviewTask_directCall_emptyDtoForwarded_uniformResultShape() {
+        TaskReviewDTO emptyDto = new TaskReviewDTO(); // approved=null, result=null
+        when(taskService.reviewTask(1L, emptyDto)).thenReturn(true);
+
+        Result<Boolean> result = controller.reviewTask(1L, emptyDto);
+
+        assertEquals(Integer.valueOf(200), result.getCode());
+        assertEquals("success", result.getMessage());
+        assertTrue(result.getData());
+        verify(taskService).reviewTask(1L, emptyDto);
+    }
+}

+ 174 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TaskReviewDecisionBoundaryServiceTest.java

@@ -0,0 +1,174 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.TaskReviewDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FamilyRelationshipScore;
+import com.etotem.cfc.entity.PointsLog;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FamilyRelationshipScoreMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.TaskExecutionMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * TDD 覆盖驱动(S-cfc-15 / 能力键 task.parent.review · GREEN 收口):
+ * TaskService.reviewTask 判定「优先级 / 缺省驳回」三处边界契约(服务层)。
+ *
+ * 判定契约(TaskService.reviewTask,TaskService.java:712-743):
+ * <pre>
+ *   if (dto.getApproved() != null) { isApproved = dto.getApproved(); }
+ *   else if ("pass".equals(dto.getResult())) { isApproved = true; }
+ *   else { isApproved = false; }   // 含 approved==null 且 result 非 pass(含 null)的缺省驳回
+ * </pre>
+ * S-cfc-13 已覆盖:approved=true(通过)/ approved=false(驳回)/ result="pass"(通过)/
+ * result="reject"(驳回)/ 重复审批幂等 / 非待审 / 不存在。本类仅补三处未显式覆盖的边界:
+ *   1. approved==null && result==null → 缺省驳回(else 兜底);
+ *   2. approved==false && result="pass" → approved 优先,仍驳回;
+ *   3. approved==true  && result="reject" → approved 优先,仍通过(发分 + completed)。
+ *
+ * 测试隔离:@InjectMocks TaskService + mock Mapper/Service,无 Spring 上下文 / 无 DB / 无外部服务;
+ * 与 TaskServiceReviewTest 同构(child.userId=null → updateProgress 提前返回;deadline=null →
+ * 积分结算走打卡类基础分分支)。不改 src/main 实现。
+ *
+ * 溯源:SC-P-11 · tests/test-cases/parent.md TC-P-06/TC-P-06-E · TaskService.reviewTask
+ */
+class TaskReviewDecisionBoundaryServiceTest {
+
+    @Mock
+    private TaskMapper taskMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private PointsLogMapper pointsLogMapper;
+    @Mock
+    private StreakService streakService;
+    @Mock
+    private EnergyService energyService;
+    @Mock
+    private CfPathwayEarnService cfPathwayEarnService;
+    @Mock
+    private UserService userService;
+    @Mock
+    private FamilyRelationshipScoreMapper familyRelationshipScoreMapper;
+    @Mock
+    private TaskExecutionMapper taskExecutionMapper;
+
+    @InjectMocks
+    private TaskService taskService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Task.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyRelationshipScore.class);
+        when(taskMapper.selectList(any())).thenReturn(new ArrayList<>());
+    }
+
+    /** 已提交待审核任务(B 已完成,等家长审批)。 */
+    private Task pendingReviewTask(Long id) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(10L);
+        t.setFamilyMemberId(11L);
+        t.setCreatorId(5L);
+        t.setTitle("每天读书30分钟");
+        t.setPoints(10);
+        t.setNeedReview(1);
+        t.setStatus("pending_review");
+        t.setCompletedAt(new Date());
+        return t;
+    }
+
+    /** 未绑定登录账号的家庭成员(totalPoints 初始 100,跳过成长任务进度更新)。 */
+    private FamilyMember child(Long id) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(null);
+        m.setTotalPoints(100);
+        m.setStreakDays(0);
+        m.setPenaltyEnabled(0);
+        return m;
+    }
+
+    // ==================== 判定优先级 / 缺省驳回 ====================
+
+    @Test
+    void reviewTask_approvedNull_resultNull_defaultsToReject_noAward() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L));
+        TaskReviewDTO dto = new TaskReviewDTO(); // approved=null, result=null → else 兜底驳回
+
+        assertTrue(taskService.reviewTask(1L, dto));
+
+        // 缺省驳回:status → pending、completedAt 清空、不发分、不触碰成员
+        ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).updateById(captor.capture());
+        assertEquals("pending", captor.getValue().getStatus());
+        assertNull(captor.getValue().getCompletedAt());
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+        verify(familyMemberMapper, never()).selectById(anyLong());
+    }
+
+    @Test
+    void reviewTask_approvedFalse_resultPass_approvedWinsReject() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L));
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setApproved(false);
+        dto.setResult("pass"); // result 声明通过,但 approved 优先 → 驳回
+
+        assertTrue(taskService.reviewTask(1L, dto));
+
+        ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).updateById(captor.capture());
+        assertEquals("pending", captor.getValue().getStatus());
+        assertNull(captor.getValue().getCompletedAt());
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+        verify(familyMemberMapper, never()).selectById(anyLong());
+    }
+
+    @Test
+    void reviewTask_approvedTrue_resultReject_approvedWinsApprove_awardsPoints() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L));
+        FamilyMember member = child(11L);
+        when(familyMemberMapper.selectById(11L)).thenReturn(member);
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setApproved(true);
+        dto.setResult("reject"); // result 声明驳回,但 approved 优先 → 通过
+
+        assertTrue(taskService.reviewTask(1L, dto));
+
+        // 通过:发分(PointsLog amount=10/type=earn)+ 余额 100→110 + 任务 completed
+        ArgumentCaptor<PointsLog> logCaptor = ArgumentCaptor.forClass(PointsLog.class);
+        verify(pointsLogMapper).insert(logCaptor.capture());
+        assertEquals(Integer.valueOf(10), logCaptor.getValue().getAmount());
+        assertEquals("earn", logCaptor.getValue().getType());
+        assertEquals(Long.valueOf(11L), logCaptor.getValue().getMemberId());
+        assertEquals(Integer.valueOf(110), member.getTotalPoints());
+
+        ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).updateById(captor.capture());
+        assertEquals("completed", captor.getValue().getStatus());
+    }
+}