|
|
@@ -0,0 +1,361 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.etotem.cfc.dto.CreateTaskDTO;
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+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.SurveyTemplateMapper;
|
|
|
+import com.etotem.cfc.mapper.TaskMapper;
|
|
|
+import com.etotem.cfc.service.TaskService;
|
|
|
+import com.etotem.cfc.service.UserService;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.BeforeAll;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+import java.util.concurrent.CyclicBarrier;
|
|
|
+import java.util.concurrent.ExecutorService;
|
|
|
+import java.util.concurrent.Executors;
|
|
|
+import java.util.concurrent.Future;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SC-P-10 创建任务+下发 契约测试(S-cfc-12,task.parent.createTask)
|
|
|
+ *
|
|
|
+ * <p>覆盖三类流(纯 Mockito,无 Spring 上下文 / 无 DB):</p>
|
|
|
+ * <ul>
|
|
|
+ * <li><b>正常流(TC-P-05)</b>:家长为孩子创建任务 → 状态 pending、执行人=B、积分/频率透传;</li>
|
|
|
+ * <li><b>异常流(TC-P-05-E)</b>:空标题 / 负积分 / 非法频率(0、负数、非枚举) / 超期截止 → 400;
|
|
|
+ * 重复提交同一创建请求(含并发双发)→ 幂等仅生成 1 条任务;</li>
|
|
|
+ * <li><b>权限流(TC-P-05-P)</b>:给非本家庭成员的成员下发 / 他人家庭家长为我的孩子创建 → 拒绝;
|
|
|
+ * 给自己分配家庭成员任务 → 拒绝。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+class TaskCreateScP10ContractTest {
|
|
|
+
|
|
|
+ private TaskService service;
|
|
|
+ private UserService userService;
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+ private TaskMapper taskMapper;
|
|
|
+
|
|
|
+ /** 模拟 DB 中已插入的任务(含窗口时间,供幂等命中) */
|
|
|
+ private final List<Task> insertedTasks = new ArrayList<>();
|
|
|
+ private long idSeq = 1000L;
|
|
|
+
|
|
|
+ @BeforeAll
|
|
|
+ static void initMybatisPlusTableInfo() {
|
|
|
+ // 使 LambdaQueryWrapper.getSqlSegment() 在无 Spring 上下文中可用(供 selectCount 桩区分 family-count/成员查询)
|
|
|
+ TableInfoHelper.initTableInfo(
|
|
|
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() throws Exception {
|
|
|
+ service = new TaskService();
|
|
|
+ userService = mock(UserService.class);
|
|
|
+ familyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
+ taskMapper = mock(TaskMapper.class);
|
|
|
+ MiniGameMapper miniGameMapper = mock(MiniGameMapper.class);
|
|
|
+ SurveyTemplateMapper surveyTemplateMapper = mock(SurveyTemplateMapper.class);
|
|
|
+
|
|
|
+ inject(service, "userService", userService);
|
|
|
+ inject(service, "familyMemberMapper", familyMemberMapper);
|
|
|
+ inject(service, "taskMapper", taskMapper);
|
|
|
+ inject(service, "miniGameMapper", miniGameMapper);
|
|
|
+ inject(service, "surveyTemplateMapper", surveyTemplateMapper);
|
|
|
+
|
|
|
+ // 用户:familyId=1
|
|
|
+ User user = new User();
|
|
|
+ user.setId(1L);
|
|
|
+ user.setFamilyId(1L);
|
|
|
+ when(userService.getUserInfo(any())).thenReturn(user);
|
|
|
+
|
|
|
+ // insert 入库(模拟 MP 自动回填主键)
|
|
|
+ when(taskMapper.insert(any(Task.class))).thenAnswer(inv -> {
|
|
|
+ Task t = inv.getArgument(0);
|
|
|
+ if (t.getId() == null) {
|
|
|
+ t.setId(++idSeq);
|
|
|
+ }
|
|
|
+ t.setCreatedAt(new Date());
|
|
|
+ insertedTasks.add(t);
|
|
|
+ return 1;
|
|
|
+ });
|
|
|
+ // 幂等窗口查询:命中已插入且创建时间在 30s 内的同内容任务
|
|
|
+ when(taskMapper.selectList(any(Wrapper.class))).thenAnswer(inv -> {
|
|
|
+ List<Task> hits = new ArrayList<>();
|
|
|
+ Date cutoff = new Date(System.currentTimeMillis() - 30_000L);
|
|
|
+ for (Task t : insertedTasks) {
|
|
|
+ if (t.getCreatedAt() != null && t.getCreatedAt().after(cutoff)) {
|
|
|
+ hits.add(t);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return hits;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void inject(Object target, String field, Object value) throws Exception {
|
|
|
+ Field f = target.getClass().getDeclaredField(field);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private CreateTaskDTO dto(String title, Integer points, Long memberId, String frequency) {
|
|
|
+ CreateTaskDTO dto = new CreateTaskDTO();
|
|
|
+ dto.setTitle(title);
|
|
|
+ dto.setPoints(points);
|
|
|
+ dto.setMemberId(memberId);
|
|
|
+ dto.setFrequency(frequency);
|
|
|
+ dto.setExecutorType("child");
|
|
|
+ dto.setNeedReview(0);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 本家庭存在该成员(selectCount=1) */
|
|
|
+ private void stubOwnFamilyMember() {
|
|
|
+ when(familyMemberMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流(TC-P-05) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_家长为孩子创建每日任务_状态待执行下发到B() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("睡前阅读", 10, 100L, "daily");
|
|
|
+
|
|
|
+ Long taskId = service.createTask(1L, dto);
|
|
|
+
|
|
|
+ assertTrue(taskId != null && taskId > 0, "应返回任务 id");
|
|
|
+ assertEquals(1, insertedTasks.size(), "仅生成 1 条任务");
|
|
|
+ Task t = insertedTasks.get(0);
|
|
|
+ assertEquals("睡前阅读", t.getTitle());
|
|
|
+ assertEquals(10, t.getPoints());
|
|
|
+ assertEquals("daily", t.getFrequency());
|
|
|
+ assertEquals("child", t.getExecutorType());
|
|
|
+ assertEquals(100L, t.getExecutorId(), "执行人应为 B");
|
|
|
+ assertEquals(100L, t.getFamilyMemberId(), "familyMemberId 应为 B(孩子列表查询口径)");
|
|
|
+ assertEquals("pending", t.getStatus(), "下发后应为待执行");
|
|
|
+ assertEquals(taskId, t.getId(), "返回 id 应为新插入任务 id");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 正常流_积分缺省_默认2分() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("默认积分", null, 100L, "daily");
|
|
|
+
|
|
|
+ service.createTask(1L, dto);
|
|
|
+
|
|
|
+ assertEquals(2, insertedTasks.get(0).getPoints());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 异常流(TC-P-05-E) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_空标题_400() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("", 10, 100L, "daily");
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("标题"), "应提示标题为空,实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_负积分_400() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("负积分任务", -5, 100L, "daily");
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("积分"), "应提示积分非法,实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_频率为0_400() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("频率0任务", 5, 100L, "0");
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("频率"), "应提示频率非法,实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_频率为负数_400() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("频率负数任务", 5, 100L, "-1");
|
|
|
+
|
|
|
+ assertThrows(IllegalArgumentException.class, () -> service.createTask(1L, dto));
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_频率非枚举_400() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("频率非法任务", 5, 100L, "999");
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("频率"), "应提示频率非法,实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_截止时间在过去_400() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("超期任务", 5, 100L, "daily");
|
|
|
+ dto.setDeadline(new Date(System.currentTimeMillis() - 3_600_000L));
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("截止"), "应提示截止时间非法,实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_重复提交同一创建请求_仅生成1条任务() {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ CreateTaskDTO dto = dto("幂等任务", 7, 100L, "daily");
|
|
|
+
|
|
|
+ Long first = service.createTask(1L, dto);
|
|
|
+ Long second = service.createTask(1L, dto);
|
|
|
+
|
|
|
+ assertEquals(first, second, "重复提交应返回同一任务 id");
|
|
|
+ assertEquals(1, insertedTasks.size(), "重复提交不应再插入新任务");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 异常流_并发双发_幂等仅生成1条任务() throws Exception {
|
|
|
+ stubOwnFamilyMember();
|
|
|
+ final CreateTaskDTO dto = dto("并发任务", 7, 100L, "daily");
|
|
|
+ final CyclicBarrier barrier = new CyclicBarrier(2);
|
|
|
+ ExecutorService pool = Executors.newFixedThreadPool(2);
|
|
|
+ try {
|
|
|
+ Future<Long> f1 = pool.submit(() -> {
|
|
|
+ barrier.await(5, TimeUnit.SECONDS);
|
|
|
+ return service.createTask(1L, dto);
|
|
|
+ });
|
|
|
+ Future<Long> f2 = pool.submit(() -> {
|
|
|
+ barrier.await(5, TimeUnit.SECONDS);
|
|
|
+ return service.createTask(1L, dto);
|
|
|
+ });
|
|
|
+ Long id1 = f1.get(15, TimeUnit.SECONDS);
|
|
|
+ Long id2 = f2.get(15, TimeUnit.SECONDS);
|
|
|
+
|
|
|
+ assertEquals(id1, id2, "并发双发应返回同一任务 id");
|
|
|
+ assertEquals(1, insertedTasks.size(), "并发双发仅生成 1 条任务");
|
|
|
+ } finally {
|
|
|
+ pool.shutdownNow();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流(TC-P-05-P) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 权限流_child下发非本家庭成员_拒绝() {
|
|
|
+ // 被分配成员不在本家庭 → selectCount=0
|
|
|
+ when(familyMemberMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
|
|
|
+ CreateTaskDTO dto = dto("越权任务", 5, 999999L, "daily");
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("不属于本家庭"), "实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 权限流_他人家庭家长为我家孩子创建任务_拒绝() {
|
|
|
+ when(familyMemberMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
|
|
|
+ // 家长 X(familyId=999)尝试给家庭1的孩子 memberId=100 下发任务
|
|
|
+ CreateTaskDTO dto = dto("X越权任务", 5, 100L, "daily");
|
|
|
+ User userX = new User();
|
|
|
+ userX.setId(99L);
|
|
|
+ userX.setFamilyId(999L);
|
|
|
+ when(userService.getUserInfo(any())).thenReturn(userX);
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(99L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("不属于本家庭"), "实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 权限流_member给自己分配任务_拒绝() {
|
|
|
+ // 家庭人数 ≥2(family-count 查询仅含 family_id,无独立 id 条件 → 2L)
|
|
|
+ when(familyMemberMapper.selectCount(any(Wrapper.class))).thenAnswer(inv -> {
|
|
|
+ Wrapper<?> w = inv.getArgument(0);
|
|
|
+ String sql = w.getSqlSegment();
|
|
|
+ return sql.matches("(?s).*\\bid\\s*=.*") ? 1L : 2L;
|
|
|
+ });
|
|
|
+ // 当前登录用户的 member 记录 id=50
|
|
|
+ FamilyMember self = new FamilyMember();
|
|
|
+ self.setId(50L);
|
|
|
+ self.setFamilyId(1L);
|
|
|
+ when(familyMemberMapper.selectOne(any(Wrapper.class))).thenReturn(self);
|
|
|
+
|
|
|
+ CreateTaskDTO dto = new CreateTaskDTO();
|
|
|
+ dto.setTitle("给自己派任务");
|
|
|
+ dto.setPoints(5);
|
|
|
+ dto.setExecutorType("member");
|
|
|
+ dto.setMemberIds(Collections.singletonList(50L));
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("不能给自己分配"), "实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void 权限流_member给非本家庭成员下发_拒绝() {
|
|
|
+ when(familyMemberMapper.selectCount(any(Wrapper.class))).thenAnswer(inv -> {
|
|
|
+ Wrapper<?> w = inv.getArgument(0);
|
|
|
+ String sql = w.getSqlSegment();
|
|
|
+ return sql.matches("(?s).*\\bid\\s*=.*") ? 0L : 2L;
|
|
|
+ });
|
|
|
+ FamilyMember self = new FamilyMember();
|
|
|
+ self.setId(50L);
|
|
|
+ self.setFamilyId(1L);
|
|
|
+ when(familyMemberMapper.selectOne(any(Wrapper.class))).thenReturn(self);
|
|
|
+
|
|
|
+ CreateTaskDTO dto = new CreateTaskDTO();
|
|
|
+ dto.setTitle("跨家庭member任务");
|
|
|
+ dto.setPoints(5);
|
|
|
+ dto.setExecutorType("member");
|
|
|
+ dto.setMemberIds(Collections.singletonList(888888L));
|
|
|
+
|
|
|
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
|
|
|
+ () -> service.createTask(1L, dto));
|
|
|
+
|
|
|
+ assertTrue(e.getMessage().contains("不属于本家庭"), "实际: " + e.getMessage());
|
|
|
+ verify(taskMapper, never()).insert(any());
|
|
|
+ }
|
|
|
+}
|