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