|
@@ -0,0 +1,373 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
|
|
+import com.etotem.cfc.controller.guide.PlanReviewController;
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
|
|
+import com.etotem.cfc.entity.TaskPlanInstance;
|
|
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import com.etotem.cfc.service.PlanActivationService;
|
|
|
|
|
+import com.etotem.cfc.service.PlannerCertificationGuard;
|
|
|
|
|
+import com.etotem.cfc.service.PlannerCertificationService;
|
|
|
|
|
+import com.etotem.cfc.service.TaskPlanService;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.Scanner;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+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.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:能力键 {@code teacher.plan.activate}(SC-T-05 激活方案拆任务·幂等 ⭐ 生成任务)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>契约溯源</b></p>
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-05 —— 激活 reviewed→active →
|
|
|
|
|
+ * 生成任务 → 成员出现启动任务;<b>边界:已激活再激活不重复生成(幂等)</b>;失败回滚不残半任务。</li>
|
|
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 成长规划师域 ——
|
|
|
|
|
+ * {@code PlanReviewController POST /api/guide/plans/activate} →
|
|
|
|
|
+ * {@code PlanActivationService.activate(planId, userId)}({@code PlannerCertificationGuard}
|
|
|
|
|
+ * 认证闸门 + {@code IllegalStateException} 状态守卫,激活幂等)。</li>
|
|
|
|
|
+ * <li>契约文档:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §2 SC-T-05 /
|
|
|
|
|
+ * §5 TR-13 第 2 条(重复激活应被状态守卫拒绝,不重复拆任务)。</li>
|
|
|
|
|
+ * <li>深挖:{@code tests/test-cases/deep-dive-plan-activation.md} —— G1 单线程幂等已成立;
|
|
|
|
|
+ * <b>G2 并发幂等缺口</b>({@code selectCount→insert} 非原子、{@code tasks} 表无
|
|
|
|
|
+ * {@code (source_type, source_id)} 唯一索引兜底)→ 并发激活同方案可能重复生成任务。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:{@link PlanActivationService#activate} + {@link PlanReviewController#activatePlan}
|
|
|
|
|
+ * + {@code tasks} 表并发幂等唯一索引。纯 Mockito / 反射,无 Spring / 无 DB / 无外部服务。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>RED 预期</b>:实现主体(状态守卫 / 认证闸门 / 单线程幂等)已合入 origin/cfclub 且行为正确,
|
|
|
|
|
+ * 相关契约用例为 <b>绿</b>;但<b>并发幂等唯一索引未落地</b>(G2,{@code tasks} 表
|
|
|
|
|
+ * {@code (source_type, source_id)} 无 UNIQUE 约束),契约要求「已激活再激活不重复生成(幂等)」
|
|
|
|
|
+ * 在并发下不成立 → {@link #并发幂等_tasks表应有source唯一索引_1方案1套任务()} 为 <b>红</b>。
|
|
|
|
|
+ * GREEN 阶段补唯一索引 / 行锁后转绿。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class PlanActivationScT05ContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String CONTROLLER = "com.etotem.cfc.controller.guide.PlanReviewController";
|
|
|
|
|
+ private static final String ACTIVATE_SERVICE = "com.etotem.cfc.service.PlanActivationService";
|
|
|
|
|
+ private static final String CERT_GUARD = "com.etotem.cfc.service.PlannerCertificationGuard";
|
|
|
|
|
+ private static final String CATEGORY_PLANNER = "planner";
|
|
|
|
|
+
|
|
|
|
|
+ private PlanActivationService service;
|
|
|
|
|
+ private TaskPlanInstanceMapper planMapper;
|
|
|
|
|
+ private UserMapper userMapper;
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+ private TaskPlanService taskPlanService;
|
|
|
|
|
+
|
|
|
|
|
+ private PlanReviewController controller;
|
|
|
|
|
+ private PlannerCertificationGuard certificationGuard;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() throws Exception {
|
|
|
|
|
+ service = new PlanActivationService();
|
|
|
|
|
+ planMapper = mock(TaskPlanInstanceMapper.class);
|
|
|
|
|
+ userMapper = mock(UserMapper.class);
|
|
|
|
|
+ familyMemberMapper = mock(FamilyMemberMapper.class);
|
|
|
|
|
+ taskPlanService = mock(TaskPlanService.class);
|
|
|
|
|
+ inject(service, "planMapper", planMapper);
|
|
|
|
|
+ inject(service, "userMapper", userMapper);
|
|
|
|
|
+ inject(service, "familyMemberMapper", familyMemberMapper);
|
|
|
|
|
+ inject(service, "taskPlanService", taskPlanService);
|
|
|
|
|
+
|
|
|
|
|
+ controller = new PlanReviewController();
|
|
|
|
|
+ certificationGuard = mock(PlannerCertificationGuard.class);
|
|
|
|
|
+ inject(controller, "planActivationService", service);
|
|
|
|
|
+ inject(controller, "plannerCertificationGuard", certificationGuard);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 static TaskPlanInstance plan(Long id, Long familyId, Long memberId, String status, Long packageId) {
|
|
|
|
|
+ TaskPlanInstance p = new TaskPlanInstance();
|
|
|
|
|
+ p.setId(id);
|
|
|
|
|
+ p.setFamilyId(familyId);
|
|
|
|
|
+ p.setMemberId(memberId);
|
|
|
|
|
+ p.setStatus(status);
|
|
|
|
|
+ p.setPackageId(packageId);
|
|
|
|
|
+ p.setName("契约测试方案");
|
|
|
|
|
+ return p;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流:SC-T-05 激活 reviewed→active ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:PlanReviewController 应暴露 POST /api/guide/plans/activate 端点")
|
|
|
|
|
+ void 正常流_controller暴露激活端点() throws Exception {
|
|
|
|
|
+ Class<?> clazz = Class.forName(CONTROLLER);
|
|
|
|
|
+ boolean hasClassMapping = false;
|
|
|
|
|
+ boolean hasActivateMapping = false;
|
|
|
|
|
+ boolean hasActivateMethod = false;
|
|
|
|
|
+ // 类级 @RequestMapping("/api/guide/plans")
|
|
|
|
|
+ if (clazz.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)) {
|
|
|
|
|
+ org.springframework.web.bind.annotation.RequestMapping rm =
|
|
|
|
|
+ clazz.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
|
|
|
|
|
+ for (String v : rm.value()) {
|
|
|
|
|
+ if ("/api/guide/plans".equals(v)) {
|
|
|
|
|
+ hasClassMapping = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if ("activatePlan".equals(m.getName())) {
|
|
|
|
|
+ hasActivateMethod = true;
|
|
|
|
|
+ if (m.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)) {
|
|
|
|
|
+ org.springframework.web.bind.annotation.PostMapping pm =
|
|
|
|
|
+ m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
|
|
|
|
|
+ for (String v : pm.value()) {
|
|
|
|
|
+ if ("/activate".equals(v)) {
|
|
|
|
|
+ hasActivateMapping = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(hasClassMapping, "[teacher.plan.activate] PlanReviewController 应映射 /api/guide/plans");
|
|
|
|
|
+ assertTrue(hasActivateMethod, "[teacher.plan.activate] PlanReviewController 应有 activatePlan 方法");
|
|
|
|
|
+ assertTrue(hasActivateMapping, "[teacher.plan.activate] activatePlan 应映射 POST /activate");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:PlanActivationService 应存在且提供 activate(planId, userId)")
|
|
|
|
|
+ void 正常流_activationService存在且提供activate方法() throws Exception {
|
|
|
|
|
+ Class<?> clazz = Class.forName(ACTIVATE_SERVICE);
|
|
|
|
|
+ boolean found = false;
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if ("activate".equals(m.getName())) {
|
|
|
|
|
+ Class<?>[] ps = m.getParameterTypes();
|
|
|
|
|
+ if (ps.length == 2 && Long.class.equals(ps[0]) && Long.class.equals(ps[1])) {
|
|
|
|
|
+ found = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(found, "[teacher.plan.activate] PlanActivationService 应提供 activate(Long planId, Long userId)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-3:PlannerCertificationGuard 认证闸门应存在(assertCertified)")
|
|
|
|
|
+ void 正常流_certificationGuard存在() throws Exception {
|
|
|
|
|
+ Class<?> clazz = Class.forName(CERT_GUARD);
|
|
|
|
|
+ boolean found = false;
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if ("assertCertified".equals(m.getName())) {
|
|
|
|
|
+ found = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(found, "[teacher.plan.activate] PlannerCertificationGuard 应提供 assertCertified 认证闸门");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-4:reviewed 方案激活后状态转 active、调用统一任务生成器")
|
|
|
|
|
+ void 正常流_激活reviewed方案_状态转active并生成任务() {
|
|
|
|
|
+ TaskPlanInstance plan = plan(100L, 10L, 20L, "reviewed", 30L);
|
|
|
|
|
+ when(planMapper.selectById(100L)).thenReturn(plan);
|
|
|
|
|
+ // 成员存在(user 域)
|
|
|
|
|
+ when(userMapper.selectById(20L)).thenReturn(new User());
|
|
|
|
|
+ when(taskPlanService.generateTasksFromTemplate(plan, 9L)).thenReturn(3);
|
|
|
|
|
+
|
|
|
|
|
+ service.activate(100L, 9L);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("active", plan.getStatus(), "激活后状态应为 active");
|
|
|
|
|
+ assertNotNull(plan.getActivatedAt(), "激活后应写入 activatedAt");
|
|
|
|
|
+ assertEquals(0, plan.getCompletedDays().intValue(), "激活后 completedDays 应为 0");
|
|
|
|
|
+ assertEquals(14, plan.getTotalDays().intValue(), "totalDays 缺省应为 14");
|
|
|
|
|
+ verify(taskPlanService).generateTasksFromTemplate(plan, 9L);
|
|
|
|
|
+ verify(planMapper).updateById(plan);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-5:成员在 family_members 域也存在时同样可激活(memberId 双 ID 域兼容)")
|
|
|
|
|
+ void 正常流_成员在familyMembers域_可激活() {
|
|
|
|
|
+ TaskPlanInstance plan = plan(101L, 10L, 21L, "reviewed", 30L);
|
|
|
|
|
+ when(planMapper.selectById(101L)).thenReturn(plan);
|
|
|
|
|
+ when(userMapper.selectById(21L)).thenReturn(null);
|
|
|
|
|
+ when(familyMemberMapper.selectById(21L)).thenReturn(new FamilyMember());
|
|
|
|
|
+
|
|
|
|
|
+ service.activate(101L, 9L);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("active", plan.getStatus());
|
|
|
|
|
+ verify(taskPlanService).generateTasksFromTemplate(plan, 9L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 边界流:状态守卫 / 幂等 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-1:方案不存在 → IllegalStateException,不拆任务")
|
|
|
|
|
+ void 边界流_方案不存在_抛异常() {
|
|
|
|
|
+ when(planMapper.selectById(999L)).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> service.activate(999L, 9L));
|
|
|
|
|
+ assertEquals("方案不存在", ex.getMessage());
|
|
|
|
|
+ verify(taskPlanService, never()).generateTasksFromTemplate(any(), anyLong());
|
|
|
|
|
+ verify(planMapper, never()).updateById(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-2:状态非 reviewed(如 generated)→ 状态守卫拒绝,不拆任务")
|
|
|
|
|
+ void 边界流_状态非reviewed_拒绝() {
|
|
|
|
|
+ TaskPlanInstance plan = plan(102L, 10L, 20L, "generated", 30L);
|
|
|
|
|
+ when(planMapper.selectById(102L)).thenReturn(plan);
|
|
|
|
|
+
|
|
|
|
|
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> service.activate(102L, 9L));
|
|
|
|
|
+ assertEquals("方案状态异常,需要先审核通过", ex.getMessage());
|
|
|
|
|
+ verify(taskPlanService, never()).generateTasksFromTemplate(any(), anyLong());
|
|
|
|
|
+ verify(planMapper, never()).updateById(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-3:已激活(active)再激活 → 状态守卫拒绝,不重复拆任务(单线程幂等)")
|
|
|
|
|
+ void 边界流_已激活再激活_拒绝不重复拆任务() {
|
|
|
|
|
+ TaskPlanInstance plan = plan(103L, 10L, 20L, "active", 30L);
|
|
|
|
|
+ when(planMapper.selectById(103L)).thenReturn(plan);
|
|
|
|
|
+
|
|
|
|
|
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> service.activate(103L, 9L));
|
|
|
|
|
+ assertEquals("方案状态异常,需要先审核通过", ex.getMessage());
|
|
|
|
|
+ verify(taskPlanService, never()).generateTasksFromTemplate(any(), anyLong());
|
|
|
|
|
+ verify(planMapper, never()).updateById(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-4:成员不存在(user 与 family_members 均无)→ 抛异常,不拆任务")
|
|
|
|
|
+ void 边界流_成员不存在_抛异常() {
|
|
|
|
|
+ TaskPlanInstance plan = plan(104L, 10L, 22L, "reviewed", 30L);
|
|
|
|
|
+ when(planMapper.selectById(104L)).thenReturn(plan);
|
|
|
|
|
+ when(userMapper.selectById(22L)).thenReturn(null);
|
|
|
|
|
+ when(familyMemberMapper.selectById(22L)).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> service.activate(104L, 9L));
|
|
|
|
|
+ assertEquals("成员不存在", ex.getMessage());
|
|
|
|
|
+ verify(taskPlanService, never()).generateTasksFromTemplate(any(), anyLong());
|
|
|
|
|
+ verify(planMapper, never()).updateById(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * <b>RED 锚点(G2 并发幂等缺口)</b>:契约 SC-T-05 要求「已激活再激活不重复生成(幂等)」,
|
|
|
|
|
+ * 深挖文档 G2 指出 {@code selectCount→insert} 非原子且 {@code tasks} 表无
|
|
|
|
|
+ * {@code (source_type, source_id)} 唯一索引兜底 → 并发激活同方案可能重复拆任务。
|
|
|
|
|
+ * 本断言契约要求 {@code tasks} 表存在 {@code (source_type, source_id)} 唯一索引,
|
|
|
|
|
+ * 保证「1 方案 → 1 套任务」。当前 schema.sql 无该约束 → 预期 <b>红</b>。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("并发幂等(RED):tasks 表应有 (source_type, source_id) 唯一索引,保证 1 方案→1 套任务")
|
|
|
|
|
+ void 并发幂等_tasks表应有source唯一索引_1方案1套任务() {
|
|
|
|
|
+ String schema = readSchemaSql();
|
|
|
|
|
+ assertTrue(schema.contains("CREATE TABLE IF NOT EXISTS tasks"),
|
|
|
|
|
+ "[teacher.plan.activate] schema.sql 缺少 tasks 建表。");
|
|
|
|
|
+ // 契约幂等兜底:1 方案 → 1 套任务(并发激活不重复生成)
|
|
|
|
|
+ // 需要 (source_type, source_id) 的唯一键(uk_ 前缀 UNIQUE KEY),普通 INDEX 无法防重。
|
|
|
|
|
+ assertTrue(schema.contains("uk_plan_source") || schema.contains("uk_source_plan")
|
|
|
|
|
+ || schema.contains("UNIQUE KEY uk_plan_source")
|
|
|
|
|
+ || schema.contains("UNIQUE KEY uk_source_plan")
|
|
|
|
|
+ || schema.contains("UNIQUE KEY uk_task_source")
|
|
|
|
|
+ || schema.contains("uk_task_plan"),
|
|
|
|
|
+ "[teacher.plan.activate] tasks 表应有 (source_type, source_id) 唯一索引(uk_plan_source 等),"
|
|
|
|
|
+ + "作为并发激活幂等兜底:保证 1 方案 → 1 套任务,重复激活不重复生成(G2 缺口)。"
|
|
|
|
|
+ + "当前 schema.sql 只有普通 INDEX idx_*,无 UNIQUE 约束。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 权限流:认证闸门 ====================
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-1:teacher 未经规划师认证 → 拒绝,不调激活")
|
|
|
|
|
+ void 权限流_teacher未认证_拒绝() {
|
|
|
|
|
+ when(certificationGuard.assertCertified(eq(9L), anyString()))
|
|
|
|
|
+ .thenReturn(Result.error(403, "规划师未完成认证"));
|
|
|
|
|
+
|
|
|
|
|
+ Result<String> r = controller.activatePlan(body(100L), 9L, "teacher", null);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(403, r.getCode().intValue());
|
|
|
|
|
+ verify(planMapper, never()).updateById(any());
|
|
|
|
|
+ verify(taskPlanService, never()).generateTasksFromTemplate(any(), anyLong());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-2:teacher 已认证 + 方案 reviewed → 激活成功")
|
|
|
|
|
+ void 权限流_teacher已认证_激活成功() {
|
|
|
|
|
+ when(certificationGuard.assertCertified(eq(9L), eq(CATEGORY_PLANNER))).thenReturn(null);
|
|
|
|
|
+ TaskPlanInstance plan = plan(200L, 10L, 20L, "reviewed", 30L);
|
|
|
|
|
+ when(planMapper.selectById(200L)).thenReturn(plan);
|
|
|
|
|
+ when(userMapper.selectById(20L)).thenReturn(new User());
|
|
|
|
|
+
|
|
|
|
|
+ Result<String> r = controller.activatePlan(body(200L), 9L, "teacher", null);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, r.getCode().intValue());
|
|
|
|
|
+ assertEquals("方案已激活", r.getData());
|
|
|
|
|
+ verify(taskPlanService).generateTasksFromTemplate(plan, 9L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-3:非 teacher 非 admin 角色 → 无权限")
|
|
|
|
|
+ void 权限流_非teacher非admin_无权限() {
|
|
|
|
|
+ Result<String> r = controller.activatePlan(body(300L), 9L, "parent", null);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("无权限", r.getMessage());
|
|
|
|
|
+ verify(planMapper, never()).updateById(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流-4:admin 免认证闸门直接激活(角色检查放行)")
|
|
|
|
|
+ void 权限流_admin免认证_放行() {
|
|
|
|
|
+ TaskPlanInstance plan = plan(400L, 10L, 20L, "reviewed", 30L);
|
|
|
|
|
+ when(planMapper.selectById(400L)).thenReturn(plan);
|
|
|
|
|
+ when(userMapper.selectById(20L)).thenReturn(new User());
|
|
|
|
|
+
|
|
|
|
|
+ Result<String> r = controller.activatePlan(body(400L), 9L, "admin", null);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(200, r.getCode().intValue());
|
|
|
|
|
+ verify(certificationGuard, never()).assertCertified(anyLong(), anyString());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 辅助 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private static Map<String, Object> body(Long planId) {
|
|
|
|
|
+ Map<String, Object> b = new HashMap<>();
|
|
|
|
|
+ b.put("planId", planId);
|
|
|
|
|
+ return b;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String readSchemaSql() {
|
|
|
|
|
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
|
|
|
|
|
+ if (is == null) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
|
|
|
|
|
+ scanner.useDelimiter("\\A");
|
|
|
|
|
+ return scanner.hasNext() ? scanner.next() : "";
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|