Просмотр исходного кода

Merge branch 'task/S-cfc-180' into cfclub

Sisyphus 48 минут назад
Родитель
Сommit
4c1dce1677

+ 373 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/PlanActivationScT05ContractTest.java

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