فهرست منبع

test(S-cfc-14): 补齐 task.parent.createTask 服务层契约单测(SC-P-10 创建任务+下发关键边界)

能力键 task.parent.createTask:TaskService.createTask 服务层边界此前零覆盖(Controller 契约测试
已由 S-cfc-12 落地且全绿)。补齐纯 Mockito 单测,无 Spring 上下文 / 无 DB / 无外部服务,不改 src/main。

新增 TaskServiceCreateTaskTest(14 例全绿):
- 正常路径:child 单成员 → 1 条实例 pending + 默认值(points=2/needReview=0/memberOnly=0/sourceType=self/repeatType=none)
- executorType 缺省 → child;显式 points/needReview 透传
- 多成员下发:memberIds=[B1,B2] → 每个成员独立实例(insert ×2)
- member 执行者边界:家庭<2 拒绝、自分配拒绝、被分配成员不在本家庭拒绝、合法分配 → assigned+needReview 缺省 1
- 小游戏类别:points/title 从小游戏缺省值补足
- 截止时间缺省 → 今天 23:59:59;isDailyProgress=1 → deadline=null
- TASK-SYS-248:前置未完成 → locked / 已完成 → pending(isFlowTask=1)
- B20 memberOnly 透传

运行结果:TaskControllerCreateTaskTest 7/7 + TaskServiceCreateTaskTest 14/14,全部通过。
Sisyphus 1 هفته پیش
والد
کامیت
eeef4557bc
1فایلهای تغییر یافته به همراه387 افزوده شده و 0 حذف شده
  1. 387 0
      cfc-backend/src/test/java/com/etotem/cfc/service/TaskServiceCreateTaskTest.java

+ 387 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/TaskServiceCreateTaskTest.java

@@ -0,0 +1,387 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.MiniGame;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+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.Arrays;
+import java.util.Calendar;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+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-14 / 能力键 task.parent.createTask):
+ * TaskService 任务创建(SC-P-10「创建任务+下发」)的服务层契约单测。
+ *
+ * 契约(对应 SC-P-10、API_REFERENCE.md §4.3 任务、TaskService.createTask/createSingleTask):
+ * - 正常路径(executorType=child,单个被分配成员):落库 1 条任务实例,status=pending,
+ *   executorId/familyMemberId/memberId=被分配成员,points 缺省 2,needReview 缺省 0,
+ *   memberOnly 缺省 0,sourceType 缺省 self,repeatType 缺省 none,isTemplate=0,isFlowTask=0
+ * - 多成员下发(memberIds 长度>1):为每个成员各建 1 条独立任务实例(insert N 次)
+ * - executorType 缺省 → 默认 child
+ * - 关键边界(executorType=member,家长给其他家庭成员布置任务):
+ *   1) 当前家庭成员数<2 → IllegalArgumentException「该任务需分配给其他家庭成员,当前家庭不足2人」
+ *   2) 分配给自己(assigneeId==currentMemberId)→ IllegalArgumentException「不能给自己分配家庭成员任务」
+ *   3) 被分配成员不属于本家庭 → IllegalArgumentException「被分配成员不属于本家庭」
+ *   4) 合法分配 → status=assigned、acceptDeadline=DTO.deadline、needReview 缺省 1、memberId=null
+ * - 小游戏任务(category=小游戏类 + minigameCode 命中启用小游戏):points/title 从小游戏缺省值补足
+ * - 截止时间缺省 → 今天 23:59:59;isDailyProgress=1 → deadline 置 null(进度型任务每日重置)
+ * - TASK-SYS-248 前置任务:前置未完成 → status=locked;前置已完成 → status=pending;isFlowTask=1
+ * - B20:memberOnly 透传(缺省 0)
+ *
+ * 角色/权限口径:createTask 无角色分支(角色不在 Service 本层判定);执行者/家庭归属校验即本层
+ * 的关键边界(上述 member 分支),身份由 Controller 从 JwtInterceptor 注入的 userId 传入。
+ *
+ * 测试隔离:@InjectMocks TaskService + mock Mapper/Service,无 Spring 上下文 / 无 DB /
+ * 无外部服务(微信等);actionType 非 survey 时 ensureSurveyTemplateForTask 提前返回,不触碰 SurveyService。
+ */
+class TaskServiceCreateTaskTest {
+
+    private static final Long USER_ID = 1L;
+    private static final Long FAMILY_ID = 10L;
+    private static final Long SELF_MEMBER_ID = 5L;
+
+    @Mock
+    private TaskMapper taskMapper;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private UserService userService;
+    @Mock
+    private MiniGameMapper miniGameMapper;
+
+    @InjectMocks
+    private TaskService taskService;
+
+    /** 被分配成员是否属于本家庭(供 selectCount 的 assignee 归属检查分支使用) */
+    private boolean assigneeInFamily = true;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // LambdaQueryWrapper 生成 SQL 片段(本测试的 selectCount thenAnswer 会调用 getSqlSegment)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Task.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), MiniGame.class);
+
+        when(userService.getUserInfo(USER_ID)).thenReturn(user(USER_ID, FAMILY_ID));
+        // getCurrentMemberId:按 userId 反查登录账号自身的家庭成员 ID
+        when(familyMemberMapper.selectOne(any(LambdaQueryWrapper.class)))
+                .thenReturn(member(SELF_MEMBER_ID, USER_ID));
+        // 默认:家庭成员总数≥2;assignee 归属检查按 assigneeInFamily 判定
+        when(familyMemberMapper.selectCount(any(LambdaQueryWrapper.class))).thenAnswer(inv -> {
+            LambdaQueryWrapper<FamilyMember> wrapper = inv.getArgument(0);
+            String sql = wrapper.getSqlSegment();
+            // 归属检查 wrapper 含独立的 id 条件("id = ...");family_id 中的 "id" 前有下划线不构成词边界
+            if (sql.matches("(?s).*\\bid\\s*=.*")) {
+                return assigneeInFamily ? 1L : 0L;
+            }
+            return 2L;
+        });
+        // insert 成功后回填自增 ID(模拟 MyBatis-Plus 主键回写)
+        AtomicLong seq = new AtomicLong(100L);
+        when(taskMapper.insert(any(Task.class))).thenAnswer(inv -> {
+            Task t = inv.getArgument(0);
+            t.setId(seq.getAndIncrement());
+            return 1;
+        });
+    }
+
+    private static User user(Long id, Long familyId) {
+        User u = new User();
+        u.setId(id);
+        u.setFamilyId(familyId);
+        return u;
+    }
+
+    private static FamilyMember member(Long id, Long userId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId);
+        m.setFamilyId(FAMILY_ID);
+        return m;
+    }
+
+    private static CreateTaskDTO childTask(String title, Long memberId) {
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle(title);
+        dto.setCategory("学习类");
+        dto.setMemberId(memberId);
+        dto.setExecutorType("child");
+        return dto;
+    }
+
+    private Task captureInsertedTask() {
+        ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper).insert(captor.capture());
+        return captor.getValue();
+    }
+
+    // ==================== 正常路径(child 执行者,单成员) ====================
+
+    @Test
+    void createTask_childSingleMember_persistsPendingTaskWithDefaults() {
+        CreateTaskDTO dto = childTask("每天读书30分钟", 11L);
+
+        Long taskId = taskService.createTask(USER_ID, dto);
+
+        assertEquals(Long.valueOf(100L), taskId);
+        Task task = captureInsertedTask();
+        assertEquals(FAMILY_ID, task.getFamilyId());
+        assertEquals(USER_ID, task.getCreatorId());
+        assertEquals("child", task.getExecutorType());
+        assertEquals(Long.valueOf(11L), task.getExecutorId());
+        assertEquals(Long.valueOf(11L), task.getFamilyMemberId());
+        assertEquals(Long.valueOf(11L), task.getMemberId());
+        assertEquals("每天读书30分钟", task.getTitle());
+        assertEquals("学习类", task.getCategory());
+        // 缺省值
+        assertEquals(Integer.valueOf(2), task.getPoints());
+        assertEquals("pending", task.getStatus());
+        assertEquals(Integer.valueOf(0), task.getNeedReview());
+        assertEquals(Integer.valueOf(0), task.getMemberOnly());
+        assertEquals("self", task.getSourceType());
+        assertEquals("none", task.getRepeatType());
+        assertEquals(Integer.valueOf(0), task.getIsTemplate());
+        assertEquals(Integer.valueOf(0), task.getIsFlowTask());
+    }
+
+    @Test
+    void createTask_executorTypeNull_defaultsToChild() {
+        CreateTaskDTO dto = childTask("默认执行者", 11L);
+        dto.setExecutorType(null);
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertEquals("child", task.getExecutorType());
+        assertEquals(Long.valueOf(11L), task.getExecutorId());
+    }
+
+    @Test
+    void createTask_explicitPointsAndNeedReview_arePreserved() {
+        CreateTaskDTO dto = childTask("自带积分", 11L);
+        dto.setPoints(10);
+        dto.setNeedReview(1);
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertEquals(Integer.valueOf(10), task.getPoints());
+        assertEquals(Integer.valueOf(1), task.getNeedReview());
+    }
+
+    // ==================== 关键边界:多成员下发 ====================
+
+    @Test
+    void createTask_multiMemberIds_createsOneInstancePerMember() {
+        CreateTaskDTO dto = childTask("早晚刷牙", null);
+        dto.setMemberIds(Arrays.asList(11L, 12L));
+
+        Long taskId = taskService.createTask(USER_ID, dto);
+
+        // 返回首个实例的 ID
+        assertEquals(Long.valueOf(100L), taskId);
+        ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
+        verify(taskMapper, times(2)).insert(captor.capture());
+        List<Task> inserted = captor.getAllValues();
+        assertEquals(Long.valueOf(11L), inserted.get(0).getMemberId());
+        assertEquals(Long.valueOf(12L), inserted.get(1).getMemberId());
+        assertEquals("pending", inserted.get(0).getStatus());
+        assertEquals("pending", inserted.get(1).getStatus());
+    }
+
+    // ==================== 关键边界:member 执行者(家长给其他家庭成员布置) ====================
+
+    @Test
+    void createTask_member_familyBelowTwoMembers_throws() {
+        // 家庭人数 1(<2)→ 拒绝
+        when(familyMemberMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("布置给家人");
+        dto.setExecutorType("member");
+        dto.setMemberIds(Arrays.asList(11L));
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> taskService.createTask(USER_ID, dto));
+        assertEquals("该任务需分配给其他家庭成员,当前家庭不足2人", ex.getMessage());
+        verify(taskMapper, never()).insert(any(Task.class));
+    }
+
+    @Test
+    void createTask_member_assignToSelf_throws() {
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("布置给自己");
+        dto.setExecutorType("member");
+        // currentMemberId=SELF_MEMBER_ID,分配给自己 → 拒绝
+        dto.setMemberIds(Arrays.asList(SELF_MEMBER_ID));
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> taskService.createTask(USER_ID, dto));
+        assertEquals("不能给自己分配家庭成员任务", ex.getMessage());
+        verify(taskMapper, never()).insert(any(Task.class));
+    }
+
+    @Test
+    void createTask_member_assigneeNotInFamily_throws() {
+        assigneeInFamily = false;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("布置给外家庭");
+        dto.setExecutorType("member");
+        dto.setMemberIds(Arrays.asList(99L));
+
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> taskService.createTask(USER_ID, dto));
+        assertEquals("被分配成员不属于本家庭", ex.getMessage());
+        verify(taskMapper, never()).insert(any(Task.class));
+    }
+
+    @Test
+    void createTask_member_validAssignee_createsAssignedTask() {
+        assigneeInFamily = true;
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("布置给家人");
+        dto.setExecutorType("member");
+        dto.setMemberIds(Arrays.asList(11L));
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertEquals("member", task.getExecutorType());
+        assertEquals("assigned", task.getStatus());
+        assertEquals(Long.valueOf(11L), task.getExecutorId());
+        assertNull(task.getMemberId());
+        // 家庭成员给其他成员布置任务,默认需要审核
+        assertEquals(Integer.valueOf(1), task.getNeedReview());
+    }
+
+    // ==================== 关键边界:小游戏任务 ====================
+
+    @Test
+    void createTask_minigame_defaultsPointsAndTitleFromMiniGame() {
+        MiniGame game = new MiniGame();
+        game.setGameCode("schulte");
+        game.setGameName("舒尔特方格");
+        game.setDefaultPoints(5);
+        when(miniGameMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(game);
+
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setCategory("小游戏类");
+        dto.setMinigameCode("schulte");
+        dto.setMemberId(11L);
+        dto.setExecutorType("child");
+        // title / points 均未指定 → 从小游戏缺省值补足
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertEquals("小游戏类", task.getCategory());
+        assertEquals("schulte", task.getMinigameCode());
+        assertEquals(Integer.valueOf(5), task.getPoints());
+        assertEquals("舒尔特方格", task.getTitle());
+    }
+
+    // ==================== 关键边界:截止时间 / 进度型任务 ====================
+
+    @Test
+    void createTask_deadlineNull_defaultsToTodayEndOfDay() {
+        CreateTaskDTO dto = childTask("无截止时间", 11L);
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertNotNull(task.getDeadline());
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(task.getDeadline());
+        assertEquals(23, cal.get(Calendar.HOUR_OF_DAY));
+        assertEquals(59, cal.get(Calendar.MINUTE));
+        assertEquals(59, cal.get(Calendar.SECOND));
+    }
+
+    @Test
+    void createTask_dailyProgress_deadlineIsNull() {
+        CreateTaskDTO dto = childTask("每日进度型", 11L);
+        dto.setIsDailyProgress(1);
+        dto.setTargetValue(30);
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertNull(task.getDeadline());
+        assertEquals(Integer.valueOf(1), task.getIsDailyProgress());
+        assertEquals(Integer.valueOf(30), task.getTargetValue());
+    }
+
+    // ==================== 关键边界:TASK-SYS-248 前置任务解锁 ====================
+
+    @Test
+    void createTask_prerequisiteNotCompleted_statusLocked() {
+        CreateTaskDTO dto = childTask("前置未完成", 11L);
+        dto.setPrerequisiteTaskId(200L);
+        Task prereq = new Task();
+        prereq.setId(200L);
+        prereq.setStatus("pending");
+        when(taskMapper.selectById(200L)).thenReturn(prereq);
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertEquals("locked", task.getStatus());
+        assertEquals(Integer.valueOf(1), task.getIsFlowTask());
+        assertEquals(Long.valueOf(200L), task.getPrerequisiteTaskId());
+    }
+
+    @Test
+    void createTask_prerequisiteCompleted_statusPending() {
+        CreateTaskDTO dto = childTask("前置已完成", 11L);
+        dto.setPrerequisiteTaskId(200L);
+        Task prereq = new Task();
+        prereq.setId(200L);
+        prereq.setStatus("completed");
+        when(taskMapper.selectById(200L)).thenReturn(prereq);
+
+        taskService.createTask(USER_ID, dto);
+
+        Task task = captureInsertedTask();
+        assertEquals("pending", task.getStatus());
+        assertEquals(Integer.valueOf(1), task.getIsFlowTask());
+    }
+
+    // ==================== 边界:memberOnly 透传(B20) ====================
+
+    @Test
+    void createTask_memberOnly_passthrough() {
+        CreateTaskDTO dto = childTask("会员任务", 11L);
+        dto.setMemberOnly(1);
+
+        taskService.createTask(USER_ID, dto);
+
+        assertEquals(Integer.valueOf(1), captureInsertedTask().getMemberOnly());
+    }
+}