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