Răsfoiți Sursa

test(tdd): S-cfc-151 task.task.review 契约级单测(TDD-RED:SC-P-11 审核闭环正常/异常流钉住 + 权限流横向越权未拦截,预期 8 例 3 红 5 绿)

Sisyphus Agent 1 zi în urmă
părinte
comite
5526b953f6

+ 325 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskReviewScP11ContractTest.java

@@ -0,0 +1,325 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.controller.task.TaskController;
+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 com.etotem.cfc.service.CfPathwayEarnService;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.StreakService;
+import com.etotem.cfc.service.TaskService;
+import com.etotem.cfc.service.UserService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.ArrayList;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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;
+
+/**
+ * 契约级单测:任务审核闭环(能力键 {@code task.task.review},S-cfc-151,TDD-RED 阶段)。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-P-11「任务审批完成(通过/驳回)」——
+ *       目标:验收孩子成果。流程:B 完成提交 → 家长待审批 → 通过发分 / 驳回重做。
+ *       边界:重复审批幂等;驳回后状态可重做。模块:{@code TaskController}。</li>
+ *   <li>设计:{@code TaskController} {@code @PostMapping("/{id}/review")};
+ *       {@code docs/superpowers/api/API_REFERENCE.md} §4.3 任务 → {@code POST /api/tasks/{id}/review}(审核任务)。</li>
+ *   <li>权限口径:{@code tests/test-cases/parent.md} TC-P-11-P——「非归属数据访问统一 403/401,
+ *       不泄露他人数据;横向越权被拒」。审核人应为任务归属家庭的家长/管理员(SC-P-11「家长待审批」)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状核实</b>(origin/cfclub,只读扫描):</p>
+ * <ul>
+ *   <li>正常流/异常流<b>已落地且已有绿测</b>({@code TaskService.reviewTask}、
+ *       {@code TaskController.reviewTask};见 {@code TaskServiceReviewTest} /
+ *       {@code TaskControllerReviewTest} / {@code TaskReviewScT06ContractTest})。</li>
+ *   <li><b>权限流未落地</b>:{@code TaskController.reviewTask(Long id, TaskReviewDTO dto)} 与
+ *       {@code TaskService.reviewTask(Long taskId, TaskReviewDTO dto)} 均<b>无审批人身份参数</b>,
+ *       无法校验「审批人 ∈ 任务归属家庭 / 审批人为家长」→ 跨家庭(横向越权)审批不被拒绝。
+ *       对照已落地的 {@code GuideFamilyTaskController.reviewTask}({@code @RequestAttribute userId +
+ *       role + familyId} 绑定校验)可证明:同域审核能力<b>应当</b>具备身份/归属校验面。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:正常流/异常流用例在实现补齐前应<b>通过</b>(钉住既有契约行为,防回归);
+ * <b>权限流</b>用例断言「审核必须携带审批人身份且拒绝跨家庭审批」,当前实现缺失该校验面
+ * → <b>失败(红)</b>。GREEN 阶段为 controller/service 增加审批人身份参数与归属校验后自动转绿。</p>
+ *
+ * <p><b>测试隔离</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务(微信等)。</p>
+ */
+class TaskReviewScP11ContractTest {
+
+    // ------------------------------------------------------------------
+    // 依赖(与 TaskServiceReviewTest 同款最小集,child.userId=null 使
+    // awardTaskCompletionRewards 内部 updateProgress 提前返回,deadline=null 走基础分分支)
+    // ------------------------------------------------------------------
+
+    @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 已完成,等家长审批;归属家庭 10 号) */
+    private Task pendingReviewTask(Long id, Long memberId, int points) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(10L);
+        t.setFamilyMemberId(memberId);
+        t.setCreatorId(5L);
+        t.setTitle("每天读书30分钟");
+        t.setPoints(points);
+        t.setNeedReview(1);
+        t.setStatus("pending_review");
+        t.setCompletedAt(new Date());
+        return t;
+    }
+
+    /** 未绑定登录账号的家庭成员(totalPoints 初始 100,跳过成长任务进度更新) */
+    private FamilyMember localMember(Long id) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(null);
+        m.setTotalPoints(100);
+        m.setStreakDays(0);
+        m.setPenaltyEnabled(0);
+        return m;
+    }
+
+    private TaskReviewDTO approveDto() {
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setApproved(true);
+        return dto;
+    }
+
+    private TaskReviewDTO rejectDto() {
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setApproved(false);
+        return dto;
+    }
+
+    // ==================================================================
+    // 正常流(既有契约行为,应绿 —— 钉住防回归)
+    // ==================================================================
+
+    @Test
+    @DisplayName("正常流-1:通过待审任务 → 置 completed 并发分")
+    void review_approve_pendingReview_completesAndAwards() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 10));
+        when(familyMemberMapper.selectById(11L)).thenReturn(localMember(11L));
+
+        assertTrue(taskService.reviewTask(1L, approveDto()));
+
+        // 任务置 completed
+        ArgumentCaptor<Task> taskCaptor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).updateById(taskCaptor.capture());
+        assertEquals("completed", taskCaptor.getValue().getStatus());
+
+        // 发分:PointsLog(amount=10, type=earn) + 成员余额 100→110
+        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(Long.valueOf(1L), logCaptor.getValue().getTaskId());
+    }
+
+    @Test
+    @DisplayName("正常流-2:驳回待审任务 → 回 pending、清空 completedAt、不发分(可重做)")
+    void review_reject_pendingReview_returnsToPending_redoable_noAward() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 10));
+
+        assertTrue(taskService.reviewTask(1L, rejectDto()));
+
+        ArgumentCaptor<Task> taskCaptor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).updateById(taskCaptor.capture());
+        assertEquals("pending", taskCaptor.getValue().getStatus());
+        assertNull(taskCaptor.getValue().getCompletedAt());
+
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+        verify(familyMemberMapper, never()).selectById(anyLong());
+    }
+
+    @Test
+    @DisplayName("正常流-3:legacy result=pass(approved=null)视同通过并发分")
+    void review_legacyResultPass_sameAsApproved() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 5));
+        when(familyMemberMapper.selectById(11L)).thenReturn(localMember(11L));
+
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setResult("pass"); // approved=null → 按 result=pass 判定通过
+        assertTrue(taskService.reviewTask(1L, dto));
+        verify(pointsLogMapper).insert(any(PointsLog.class));
+    }
+
+    // ==================================================================
+    // 异常流(既有契约行为,应绿)
+    // ==================================================================
+
+    @Test
+    @DisplayName("异常流-1:任务不存在 → false 且不落库")
+    void review_taskNotFound_returnsFalse_noDbWrite() {
+        when(taskMapper.selectById(99L)).thenReturn(null);
+
+        assertFalse(taskService.reviewTask(99L, approveDto()));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+        verify(taskMapper, never()).updateById(any(Task.class));
+    }
+
+    @Test
+    @DisplayName("异常流-2:非待审状态幂等处理(completed→true 不重复发分;pending→false)")
+    void review_nonReviewStatus_idempotentOrFalse() {
+        // completed(已通过)→ true 且不重复发分
+        Task done = pendingReviewTask(1L, 11L, 10);
+        done.setStatus("completed");
+        when(taskMapper.selectById(1L)).thenReturn(done);
+        assertTrue(taskService.reviewTask(1L, approveDto()));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+
+        // pending(未提交/驳回后未重做)→ false
+        Task pending = pendingReviewTask(2L, 11L, 10);
+        pending.setStatus("pending");
+        when(taskMapper.selectById(2L)).thenReturn(pending);
+        assertFalse(taskService.reviewTask(2L, approveDto()));
+    }
+
+    // ==================================================================
+    // 权限流(契约要求但实现缺失 → 应红)
+    // ==================================================================
+
+    @Test
+    @DisplayName("权限流-1:审核 handler 必须携带审批人身份参数(userId/currentMemberId/role)——当前缺失")
+    void permission_handler_mustDeclareReviewerIdentity() {
+        Method m = declaredMethod(TaskController.class, "reviewTask");
+        assertTrue(m != null,
+                "[task.task.review] TaskController.reviewTask 方法应存在(@PostMapping(\"/{id}/review\"))。");
+
+        boolean hasIdentityParam = false;
+        for (Parameter p : m.getParameters()) {
+            String type = p.getType().getSimpleName();
+            // 审批人身份:登录用户ID / 当前成员ID / 角色属性,三者任一即具备身份校验面
+            if (type.equals("Long") || type.equals("String")) {
+                if (p.isAnnotationPresent(org.springframework.web.bind.annotation.RequestAttribute.class)) {
+                    hasIdentityParam = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(hasIdentityParam,
+                "[task.task.review] TaskController.reviewTask 应携带审批人身份参数"
+                        + "(@RequestAttribute userId/currentMemberId/role),以校验「审批人 ∈ 任务归属家庭」"
+                        + "并拒绝横向越权(SC-P-11 家长待审批 / TC-P-11-P)。"
+                        + "当前签名 (Long id, TaskReviewDTO dto) 无任何身份参数——跨家庭审批不被拒绝,契约未满足。");
+    }
+
+    @Test
+    @DisplayName("权限流-2:审核服务必须接受审批人作用域(reviewerId),以实施归属家庭校验——当前缺失")
+    void permission_service_mustAcceptReviewerScope() {
+        Method m = declaredMethod(TaskService.class, "reviewTask");
+        assertTrue(m != null,
+                "[task.task.review] TaskService.reviewTask 方法应存在。");
+
+        // 契约要求:reviewTask(Long taskId, TaskReviewDTO dto) 之外,还需一个审批人身份/作用域参数
+        boolean hasReviewerScope = false;
+        for (Parameter p : m.getParameters()) {
+            String name = p.getName().toLowerCase();
+            String type = p.getType().getSimpleName().toLowerCase();
+            if ((name.contains("reviewer") || name.contains("userid") || name.contains("memberid"))
+                    && (type.contains("long"))) {
+                hasReviewerScope = true;
+                break;
+            }
+        }
+        assertTrue(hasReviewerScope,
+                "[task.task.review] TaskService.reviewTask 应接受审批人身份/作用域参数(reviewerId/userId),"
+                        + "以便在服务层校验「审批人属于任务归属家庭且为家长/管理员」并拒绝横向越权(TC-P-11-P)。"
+                        + "当前签名 (Long taskId, TaskReviewDTO dto) 无审批人维度——任意调用方可审批任意家庭任务,契约未满足。");
+    }
+
+    @Test
+    @DisplayName("权限流-3:无审批人身份上下文时审核必须被拒绝(不发分、不改状态)——当前直接放行")
+    void permission_crossFamilyReview_mustBeRejected() {
+        // 任务归属家庭 10 号(pendingReviewTask 固定 familyId=10);
+        // 契约要求:无审批人身份/归属校验时,审核不得进入发分/改状态链路(横向越权被拒)。
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 10));
+        when(familyMemberMapper.selectById(11L)).thenReturn(localMember(11L));
+
+        // 当前实现:仅凭 taskId 即可审批(无审批人身份),直接走发分链路 → 契约要求拒绝,此处应红
+        boolean accepted = taskService.reviewTask(1L, approveDto());
+
+        assertFalse(accepted,
+                "[task.task.review] 无审批人身份上下文(跨家庭/匿名)时 reviewTask 应被拒绝(返回 false 或不发分),"
+                        + "但当前实现直接放行并进入发分链路——横向越权(TC-P-11-P)未拦截。");
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+        verify(taskMapper, never()).updateById(any(Task.class));
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private static Method declaredMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) {
+                return m;
+            }
+        }
+        return null;
+    }
+}