浏览代码

test(S-cfc-13): 补齐 task.parent.review 契约级单测(SC-P-11 任务审批完成通过/驳回)

能力键 task.parent.review:TaskController/TaskService 审批闭环(review/accept/reject/pending-review)
实现已存在但零测试覆盖(RED)。补齐纯 Mockito 单测,不依赖 DB/真实微信/Spring 容器,不改 src/main。

新增测试(32 例全绿,skipTests=true 需 -DskipTests=false 显式开启):
- TaskControllerReviewTest(17 例):POST /api/tasks/{id}/review(approved 通过/驳回、DTO 字段映射、
  统一 @PostMapping GET→405、缺 body 400、直接调用 Result<T>);POST /api/tasks/pending-review
  (parent 列表、缺 userId 400);POST /api/tasks/accept(body/currentMemberId 派生 memberId、
  缺 memberId 500、GET→405);POST /api/tasks/reject/{id}(成功、缺 memberId 500、GET→405)
- TaskServiceReviewTest(15 例):reviewTask 通过→发分+completed(PointsLog amount/type、余额 100→110、
  legacy result=pass);驳回→pending+completedAt 清空(可重做)+不发分(legacy result≠pass 同);
  重复审批幂等(已 completed→true 但不重复发分/不落库);非待审状态→false;任务不存在→false;
  acceptTask(assigned+正确执行人→in_progress+清 acceptDeadline;错误执行人/非 assigned→false);
  rejectTask(assigned→rejected+双向关系分调整;错误执行人→false 不触关系分);
  getPendingReviewTasks(仅 parent 可见本家庭列表;child/不存在→空列表不泄露)

暴露缺陷(不修实现,交编排侧转 GREEN 决策):
- 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_defect 钉住当前观测行为,修复后应翻转为 200 契约断言。

溯源:SC-P-11 · tests/user-scenarios/应用场景总览.md · tests/test-cases/parent.md TC-P-06/TC-P-06-E
设计:docs/superpowers/api/API_REFERENCE.md §4.3 · docs/superpowers/specs/2026-09-04-task-system-unification-design.md
Sisyphus 1 周之前
父节点
当前提交
291955bce

+ 324 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/task/TaskControllerReviewTest.java

@@ -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&lt;Boolean&gt; 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());
+    }
+}

+ 336 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TaskServiceReviewTest.java

@@ -0,0 +1,336 @@
+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.entity.User;
+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.Arrays;
+import java.util.Date;
+import java.util.List;
+
+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.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * TDD 覆盖驱动(S-cfc-13 / 能力键 task.parent.review):
+ * TaskService 任务审批闭环(SC-P-11「任务审批完成(通过/驳回)」)的服务层契约单测。
+ *
+ * 契约(对应 SC-P-11 流程与边界、API_REFERENCE.md §4.3、TaskService.reviewTask/acceptTask/rejectTask/getPendingReviewTasks):
+ * - B 完成提交 → status=pending_review,家长审批:
+ *   - 通过(approved=true 或 legacy result="pass")→ 发分(PointsLog + 成员 totalPoints)+ 任务置 completed
+ *   - 驳回(approved=false 或 result 非 pass)→ 任务置回 pending、清空 completedAt → B 可重做
+ * - 边界:重复审批幂等(已 completed 再审批 → true 但不重复发分、无二次入账);
+ *   非待审状态(pending 等)→ false;任务不存在 → false
+ * - acceptTask:仅 assigned 且 executorId==memberId 可接受 → in_progress 并清 acceptDeadline;否则 false
+ * - rejectTask:仅 assigned 且 executorId==memberId 可拒绝 → rejected + 双向关系分调整;否则 false
+ * - getPendingReviewTasks:仅 user.role=parent 可见本家庭待审列表;child/teacher 等角色 → 空列表(不泄露)
+ *
+ * 测试隔离:@InjectMocks TaskService + mock Mapper/Service,无 Spring 上下文 / 无 DB /
+ * 无外部服务;child.userId=null 使 awardTaskCompletionRewards 内部 updateProgress 提前返回,
+ * deadline=null 使积分结算走「打卡类任务基础分」分支(pointsEarned=task.points,type=earn)。
+ */
+class TaskServiceReviewTest {
+
+    @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, 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 child(Long id) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(null);
+        m.setTotalPoints(100);
+        m.setStreakDays(0);
+        m.setPenaltyEnabled(0);
+        return m;
+    }
+
+    /** 待接受的指派任务(executor=memberId,status=assigned) */
+    private Task assignedTask(Long id, Long memberId) {
+        Task t = new Task();
+        t.setId(id);
+        t.setFamilyId(10L);
+        t.setCreatorId(5L);
+        t.setExecutorType("member");
+        t.setExecutorId(memberId);
+        t.setStatus("assigned");
+        t.setAcceptDeadline(new Date());
+        return t;
+    }
+
+    private TaskReviewDTO dtoApproved(boolean approved) {
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setApproved(approved);
+        return dto;
+    }
+
+    // ==================== reviewTask 通过 → 发分 ====================
+
+    @Test
+    void reviewTask_approve_pointsAwarded_taskCompleted() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 10));
+        FamilyMember member = child(11L);
+        when(familyMemberMapper.selectById(11L)).thenReturn(member);
+
+        assertTrue(taskService.reviewTask(1L, dtoApproved(true)));
+
+        // 发分: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());
+        assertEquals(Integer.valueOf(110), member.getTotalPoints());
+
+        // 任务置 completed
+        ArgumentCaptor<Task> taskCaptor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).updateById(taskCaptor.capture());
+        assertEquals("completed", taskCaptor.getValue().getStatus());
+    }
+
+    @Test
+    void reviewTask_approve_legacyResultPass_sameAsApproved() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 5));
+        when(familyMemberMapper.selectById(11L)).thenReturn(child(11L));
+
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setResult("pass"); // approved=null → 按 result=pass 判定通过
+
+        assertTrue(taskService.reviewTask(1L, dto));
+        verify(pointsLogMapper).insert(any(PointsLog.class));
+    }
+
+    // ==================== reviewTask 驳回 → 可重做 ====================
+
+    @Test
+    void reviewTask_reject_returnsToPending_redoEnabled_noAward() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 10));
+
+        assertTrue(taskService.reviewTask(1L, dtoApproved(false)));
+
+        // 驳回:status → pending、completedAt 清空 → B 可重做提交
+        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
+    void reviewTask_reject_legacyResultNonPass_sameAsReject() {
+        when(taskMapper.selectById(1L)).thenReturn(pendingReviewTask(1L, 11L, 5));
+
+        TaskReviewDTO dto = new TaskReviewDTO();
+        dto.setResult("reject"); // approved=null 且非 pass → 驳回
+
+        assertTrue(taskService.reviewTask(1L, dto));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+    }
+
+    // ==================== reviewTask 边界:重复审批幂等 / 非待审 / 不存在 ====================
+
+    @Test
+    void reviewTask_repeatApproval_idempotent_noDoubleAward() {
+        Task task = pendingReviewTask(1L, 11L, 10);
+        task.setStatus("completed"); // 已审批通过,再次调用
+        when(taskMapper.selectById(1L)).thenReturn(task);
+
+        assertTrue(taskService.reviewTask(1L, dtoApproved(true)));
+
+        // 幂等:不再发分、不重复落库
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+        verify(familyMemberMapper, never()).selectById(anyLong());
+        verify(taskMapper, never()).updateById(any(Task.class));
+    }
+
+    @Test
+    void reviewTask_nonReviewState_returnsFalse() {
+        Task task = pendingReviewTask(1L, 11L, 10);
+        task.setStatus("pending"); // 未提交审批(如驳回后尚未重做)
+        when(taskMapper.selectById(1L)).thenReturn(task);
+
+        assertFalse(taskService.reviewTask(1L, dtoApproved(true)));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+    }
+
+    @Test
+    void reviewTask_taskNotFound_returnsFalse() {
+        when(taskMapper.selectById(99L)).thenReturn(null);
+
+        assertFalse(taskService.reviewTask(99L, dtoApproved(true)));
+        verify(pointsLogMapper, never()).insert(any(PointsLog.class));
+    }
+
+    // ==================== acceptTask:指派任务的接受 ====================
+
+    @Test
+    void acceptTask_assignedMember_goesInProgress() {
+        Task task = assignedTask(1L, 11L);
+        when(taskMapper.selectById(1L)).thenReturn(task);
+
+        assertTrue(taskService.acceptTask(1L, 11L));
+
+        assertEquals("in_progress", task.getStatus());
+        assertNull(task.getAcceptDeadline());
+        verify(taskMapper).updateById(task);
+    }
+
+    @Test
+    void acceptTask_wrongExecutor_returnsFalse() {
+        when(taskMapper.selectById(1L)).thenReturn(assignedTask(1L, 12L));
+
+        assertFalse(taskService.acceptTask(1L, 11L));
+        verify(taskMapper, never()).updateById(any(Task.class));
+    }
+
+    @Test
+    void acceptTask_notAssignedStatus_returnsFalse() {
+        Task task = assignedTask(1L, 11L);
+        task.setStatus("in_progress");
+        when(taskMapper.selectById(1L)).thenReturn(task);
+
+        assertFalse(taskService.acceptTask(1L, 11L));
+        verify(taskMapper, never()).updateById(any(Task.class));
+    }
+
+    // ==================== rejectTask:指派任务的拒绝 ====================
+
+    @Test
+    void rejectTask_assignedMember_markedRejected_relationshipAdjusted() {
+        Task task = assignedTask(1L, 11L);
+        when(taskMapper.selectById(1L)).thenReturn(task);
+        // 双向关系分初始都不存在 → 各新建一条(insert)+ 增量更新(updateById)
+        when(familyRelationshipScoreMapper.selectOne(any())).thenReturn(null);
+        when(familyMemberMapper.selectById(11L)).thenReturn(child(11L)); // from 成员既有信任度
+        when(familyMemberMapper.selectById(5L)).thenReturn(null);        // creator 无成员行 → 默认 60
+
+        assertTrue(taskService.rejectTask(1L, 11L));
+
+        assertEquals("rejected", task.getStatus());
+        verify(taskMapper).updateById(task);
+        // 两次 adjustRelationshipScore:trust-1/intimacy-1/comm-1(assignee→creator)+ comm+1(creator→assignee)
+        verify(familyRelationshipScoreMapper, times(2)).insert(any(FamilyRelationshipScore.class));
+        verify(familyRelationshipScoreMapper, times(2)).updateById(any(FamilyRelationshipScore.class));
+    }
+
+    @Test
+    void rejectTask_wrongExecutor_returnsFalse_noRelationshipTouch() {
+        when(taskMapper.selectById(1L)).thenReturn(assignedTask(1L, 12L));
+
+        assertFalse(taskService.rejectTask(1L, 11L));
+        verify(taskMapper, never()).updateById(any(Task.class));
+        verify(familyRelationshipScoreMapper, never()).selectOne(any());
+    }
+
+    // ==================== getPendingReviewTasks:家长角色可见性 ====================
+
+    @Test
+    void pendingReview_parentRole_returnsFamilyPendingList() {
+        User parent = new User();
+        parent.setId(7L);
+        parent.setRole("parent");
+        parent.setFamilyId(10L);
+        when(userService.getUserInfo(7L)).thenReturn(parent);
+        when(taskMapper.selectList(any())).thenReturn(Arrays.asList(pendingReviewTask(1L, 11L, 10)));
+
+        List<Task> result = taskService.getPendingReviewTasks(7L);
+
+        assertEquals(1, result.size());
+        assertEquals(Long.valueOf(10L), result.get(0).getFamilyId());
+        verify(taskMapper).selectList(any());
+    }
+
+    @Test
+    void pendingReview_nonParentRole_returnsEmpty_noQuery() {
+        User childUser = new User();
+        childUser.setId(7L);
+        childUser.setRole("child");
+        when(userService.getUserInfo(7L)).thenReturn(childUser);
+
+        List<Task> result = taskService.getPendingReviewTasks(7L);
+
+        assertTrue(result.isEmpty());
+        verify(taskMapper, never()).selectList(any());
+    }
+
+    @Test
+    void pendingReview_userNotFound_returnsEmpty() {
+        when(userService.getUserInfo(99L)).thenReturn(null);
+
+        assertTrue(taskService.getPendingReviewTasks(99L).isEmpty());
+        verify(taskMapper, never()).selectList(any());
+    }
+}