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

test(S-cfc-196): teacher.plan.create RED contract - 权限流角色门禁未落地 (4 RED/9 green)

能力键 teacher.plan.create (SC-T-08 成长计划/套餐创建 · SC-T-14 方案编辑规划师侧)
契约来源: API_REFERENCE §4.57 + EVD-10 planner-zone-contract (77026df42) §3.

- 端点契约: TrainingPlanController /api/guide/training-plans/* (create/update/list/detail/delete),
  GrowthPlanController /api/growth/plan/create|update, HealthPlanController /api/health/plan/save|generate
- 正常流: teacher 创建套餐/成长计划 -> 200 + 落库
- 异常流: 缺 memberId/planId -> 拒绝且不落库
- 权限流 (RED): create/update 端点未暴露 role/roles 输入(角色门禁缺失),
  非 teacher 角色亦可创建/修改方案 -> 契约行为缺失,GREEN 阶段补齐后转绿

TDD-RED 预期: 13 run / 4 fail / 0 error / 9 pass
E2E Test Bot 2 часов назад
Родитель
Сommit
fb1702975d

+ 313 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TeacherPlanCreateContractTest.java

@@ -0,0 +1,313 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.growth.GrowthPlanController;
+import com.etotem.cfc.controller.guide.TrainingPlanController;
+import com.etotem.cfc.entity.GrowthPlan;
+import com.etotem.cfc.service.GrowthPlanService;
+import com.etotem.cfc.service.TrainingPlanService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+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.create}(SC-T-08 成长计划 / 套餐创建 · SC-T-14 方案编辑规划师侧)。
+ *
+ * <p><b>契约锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code docs/tdd/orchestration/sc-key-map.md} SC-T-08 / SC-T-14(规划师域)。
+ *       <b>来源追溯</b>:EVD-10 契约核对(commit 77026df42,planner-zone 32 SC 端点核验表),
+ *       能力键注册于 {@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md} §3
+ *       「建议能力键映射」行 {@code teacher.plan.create}(绑定 SC-T-08 / T-14)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-08 / SC-T-14 行 ——
+ *       {@code TrainingPlanController /api/guide/training-plans/*}(create/update/list/detail/delete);
+ *       {@code GrowthPlanController /api/growth/plan/create}、{@code /update};
+ *       {@code HealthPlanController /api/health/plan/save}、{@code /generate}。
+ *       角色模型:成长规划师({@code role=teacher}),规划师端点按项目框架约定
+ *       (根 AGENTS.md「角色控制」:控制器内手动检查 {@code @RequestAttribute("role")})
+ *       校验 {@code teacher/admin},与同域端点同构(参照 {@code GuideFamilyQueryController.myFamilies}
+ *       的 {@code RolesUtil.hasTeacherRole(role, roles)} 403 门禁)。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(2026-11-13 实测 origin/cfclub)</b>:端点载体已落地(TrainingPlanController /
+ * GrowthPlanController / HealthPlanController 均在),但<b>规划师端点角色门禁未落地</b>——
+ * {@code TrainingPlanController.createPlan/updatePlan} 与 {@code GrowthPlanController.createPlan/updatePlan}
+ * 均未读取 {@code role}/{@code roles} 请求属性(与同域 {@code GuideFamilyQueryController} /
+ * {@code PlanReviewController} / {@code HealthPlanController.pending-review/*} 等已含
+ * {@code RolesUtil.hasTeacherRole} 门禁的端点不同构),非 teacher 角色亦可创建/修改方案。
+ * 本测试以「权限流角色门禁缺失」为 RED 锚点:断言该契约行为在 GREEN 阶段补齐前失败(红),
+ * 端点/正常流/异常流用例先登记为通过(绿)作为能力键契约证据。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射 / 无 Spring / 无 DB / 无外部服务;断言不依赖真实外部服务。
+ * 三类流:正常流(认证 teacher 创建/更新成功)、异常流(缺 memberId/planId 拒绝且不落库)、
+ * 权限流(角色门禁契约:create/update 端点须暴露 {@code role}/{@code roles} 输入并拒绝非 teacher,
+ * 当前未落地 → 红)。</p>
+ *
+ * <p><b>RED 预期</b>:权限流 4 例(training create/update、growth create/update 的角色门禁
+ * 结构探针)在实现补齐前失败(红),断言原文为「角色门禁未落地」。</p>
+ */
+class TeacherPlanCreateContractTest extends AssessmentPlanContractTestBase {
+
+    // ==================== 端点契约(API_REFERENCE §4.57 SC-T-08 / T-14 载体) ====================
+
+    @Test
+    @DisplayName("端点契约:TrainingPlanController @POST /api/guide/training-plans/* 载体存在")
+    void endpoint_trainingPlan_createUpdateCarriers() {
+        Class<TrainingPlanController> clazz = TrainingPlanController.class;
+        assertClassMapping(clazz, "/api/guide/training-plans",
+                "TrainingPlanController 类级映射应为 /api/guide/training-plans(§4.57 SC-T-08)");
+        assertPostMapping(clazz, "createPlan", "/create",
+                "TrainingPlanController.createPlan 应为 POST /create(§4.57 SC-T-08 套餐创建)");
+        assertPostMapping(clazz, "updatePlan", "/update",
+                "TrainingPlanController.updatePlan 应为 POST /update(§4.57 SC-T-08 套餐编辑)");
+        assertPostMapping(clazz, "getPlanList", "/list",
+                "TrainingPlanController.getPlanList 应为 POST /list(§4.57 SC-T-08 列表)");
+        assertPostMapping(clazz, "getPlanDetail", "/detail",
+                "TrainingPlanController.getPlanDetail 应为 POST /detail(§4.57 SC-T-08 详情)");
+        assertPostMapping(clazz, "deletePlan", "/delete",
+                "TrainingPlanController.deletePlan 应为 POST /delete(§4.57 SC-T-08 删除)");
+    }
+
+    @Test
+    @DisplayName("端点契约:GrowthPlanController @POST /api/growth/plan/* 载体存在")
+    void endpoint_growthPlan_createUpdateCarriers() {
+        Class<GrowthPlanController> clazz = GrowthPlanController.class;
+        assertClassMapping(clazz, "/api/growth/plan",
+                "GrowthPlanController 类级映射应为 /api/growth/plan(§4.57 SC-T-08/T-14)");
+        assertPostMapping(clazz, "createPlan", "/create",
+                "GrowthPlanController.createPlan 应为 POST /create(§4.57 SC-T-08 成长计划创建)");
+        assertPostMapping(clazz, "updatePlan", "/update",
+                "GrowthPlanController.updatePlan 应为 POST /update(§4.57 SC-T-14 方案编辑)");
+        assertPostMapping(clazz, "getActivePlan", "/child/{memberId}/active",
+                "GrowthPlanController.getActivePlan 应为 POST /child/{memberId}/active(§4.57 SC-T-17)");
+    }
+
+    @Test
+    @DisplayName("端点契约:HealthPlanController @POST /api/health/plan/save|generate 载体存在")
+    void endpoint_healthPlan_saveGenerateCarriers() {
+        Class<?> clazz = loadClass("com.etotem.cfc.controller.HealthPlanController");
+        assertNotNull(clazz, "HealthPlanController 类不存在(§4.57 SC-T-14 载体)。");
+        assertClassMapping(clazz, "/api/health/plan",
+                "HealthPlanController 类级映射应为 /api/health/plan(§4.57 SC-T-14)");
+        assertPostMapping(clazz, "save", "/save",
+                "HealthPlanController.save 应为 POST /save(§4.57 SC-T-14 健康方案保存)");
+        assertPostMapping(clazz, "generate", "/generate",
+                "HealthPlanController.generate 应为 POST /generate(§4.57 SC-T-14 健康方案生成)");
+    }
+
+    // ==================== 正常流:认证 teacher 创建方案(行为) ====================
+
+    @Test
+    @DisplayName("SC-T-08 正常:teacher 创建套餐 → 200 返回 planId + 落库(source=manual, status=draft)")
+    void trainingPlan_create_normal_teacher() throws Exception {
+        TrainingPlanService svc = mock(TrainingPlanService.class);
+        TrainingPlanController c = new TrainingPlanController();
+        inject(c, "trainingPlanService", svc);
+        when(svc.createPlan(7L, 10L, "暑期认知训练", "专注力专项", "manual")).thenReturn(501L);
+
+        Map<String, Object> body = new HashMap<>();
+        body.put("memberId", 10L);
+        body.put("title", "暑期认知训练");
+        body.put("description", "专注力专项");
+        Result<Long> r = c.createPlan(7L, body, null);
+
+        assertEquals(200, r.getCode());
+        assertEquals(501L, r.getData());
+        verify(svc).createPlan(7L, 10L, "暑期认知训练", "专注力专项", "manual");
+    }
+
+    @Test
+    @DisplayName("SC-T-08 正常:teacher 创建成长计划 → 200 + teacherId 绑定 + 落库")
+    void growthPlan_create_normal_teacher() throws Exception {
+        GrowthPlanService svc = mock(GrowthPlanService.class);
+        GrowthPlanController c = new GrowthPlanController();
+        inject(c, "growthPlanService", svc);
+        inject(c, "userMapper", mock(com.etotem.cfc.mapper.UserMapper.class));
+        inject(c, "growthPlanMapper", mock(com.etotem.cfc.mapper.GrowthPlanMapper.class));
+        when(svc.createPlan(any(GrowthPlan.class))).thenAnswer(inv -> inv.getArgument(0));
+
+        GrowthPlan plan = new GrowthPlan();
+        plan.setMemberId(10L);
+        plan.setPlanTitle("DAN测评-成长方案");
+        Result<GrowthPlan> r = c.createPlan(plan, 7L);
+
+        assertEquals(200, r.getCode());
+        assertEquals(7L, plan.getTeacherId());
+        assertEquals(7L, r.getData().getTeacherId());
+        verify(svc).createPlan(any(GrowthPlan.class));
+    }
+
+    // ==================== 异常流:缺参拒绝且不落库 ====================
+
+    @Test
+    @DisplayName("SC-T-08 异常:套餐创建缺 memberId → 拒绝,不落库")
+    void trainingPlan_create_exception_missingMemberId() throws Exception {
+        TrainingPlanService svc = mock(TrainingPlanService.class);
+        TrainingPlanController c = new TrainingPlanController();
+        inject(c, "trainingPlanService", svc);
+
+        Result<Long> r = c.createPlan(7L, new HashMap<>(), null);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("memberId不能为空"));
+        verify(svc, never()).createPlan(any(), any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("SC-T-08 异常:成长计划创建缺 memberId → 拒绝,不落库")
+    void growthPlan_create_exception_missingMemberId() throws Exception {
+        GrowthPlanService svc = mock(GrowthPlanService.class);
+        GrowthPlanController c = new GrowthPlanController();
+        inject(c, "growthPlanService", svc);
+        inject(c, "userMapper", mock(com.etotem.cfc.mapper.UserMapper.class));
+        inject(c, "growthPlanMapper", mock(com.etotem.cfc.mapper.GrowthPlanMapper.class));
+
+        Result<GrowthPlan> r = c.createPlan(new GrowthPlan(), 7L);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("请选择家庭成员"));
+        verify(svc, never()).createPlan(any(GrowthPlan.class));
+    }
+
+    @Test
+    @DisplayName("SC-T-08 异常:套餐更新缺 planId → 拒绝")
+    void trainingPlan_update_exception_missingPlanId() throws Exception {
+        TrainingPlanService svc = mock(TrainingPlanService.class);
+        TrainingPlanController c = new TrainingPlanController();
+        inject(c, "trainingPlanService", svc);
+
+        Result<Boolean> r = c.updatePlan(new HashMap<>());
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("planId不能为空"));
+        verify(svc, never()).updatePlan(any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("SC-T-14 异常:成长计划更新缺 id → 拒绝")
+    void growthPlan_update_exception_missingId() throws Exception {
+        GrowthPlanService svc = mock(GrowthPlanService.class);
+        GrowthPlanController c = new GrowthPlanController();
+        inject(c, "growthPlanService", svc);
+        inject(c, "userMapper", mock(com.etotem.cfc.mapper.UserMapper.class));
+        inject(c, "growthPlanMapper", mock(com.etotem.cfc.mapper.GrowthPlanMapper.class));
+
+        Result<Boolean> r = c.updatePlan(new HashMap<>());
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("方案ID不能为空"));
+        verify(svc, never()).updatePlan(any());
+    }
+
+    // ==================== 权限流:角色门禁契约(RED 锚点) ====================
+    // 规划师端点按框架约定应校验 teacher/admin(RolesUtil.hasTeacherRole),
+    // 参照同域 GuideFamilyQueryController.myFamilies / PlanReviewController.activatePlan /
+    // HealthPlanController.pending-review/*。当前 create/update 端点未读取 role/roles → 红。
+
+    @Test
+    @DisplayName("权限流:套餐创建 createPlan 应暴露 role/roles 输入(角色门禁)——当前未落地")
+    void trainingPlan_create_permission_roleGate() {
+        assertRoleGate(TrainingPlanController.class, "createPlan",
+                "[teacher.plan.create] TrainingPlanController.createPlan(POST /api/guide/training-plans/create)"
+                        + " 未暴露 role/roles 请求属性:规划师端点应校验 teacher/admin 角色"
+                        + "(参照 GuideFamilyQueryController.myFamilies 的 RolesUtil.hasTeacherRole 403 门禁)。"
+                        + " 角色门禁未落地,非 teacher 角色亦可创建方案。");
+    }
+
+    @Test
+    @DisplayName("权限流:套餐更新 updatePlan 应暴露 role/roles 输入(角色门禁)——当前未落地")
+    void trainingPlan_update_permission_roleGate() {
+        assertRoleGate(TrainingPlanController.class, "updatePlan",
+                "[teacher.plan.create] TrainingPlanController.updatePlan(POST /api/guide/training-plans/update)"
+                        + " 未暴露 role/roles 请求属性:规划师端点应校验 teacher/admin 角色。角色门禁未落地。");
+    }
+
+    @Test
+    @DisplayName("权限流:成长计划创建 createPlan 应暴露 role/roles 输入(角色门禁)——当前未落地")
+    void growthPlan_create_permission_roleGate() {
+        assertRoleGate(GrowthPlanController.class, "createPlan",
+                "[teacher.plan.create] GrowthPlanController.createPlan(POST /api/growth/plan/create)"
+                        + " 未暴露 role/roles 请求属性:规划师端点应校验 teacher/admin 角色。角色门禁未落地。");
+    }
+
+    @Test
+    @DisplayName("权限流:成长计划更新 updatePlan 应暴露 role/roles 输入(角色门禁)——当前未落地")
+    void growthPlan_update_permission_roleGate() {
+        assertRoleGate(GrowthPlanController.class, "updatePlan",
+                "[teacher.plan.create] GrowthPlanController.updatePlan(POST /api/growth/plan/update)"
+                        + " 未暴露 role/roles 请求属性:规划师端点应校验 teacher/admin 角色。角色门禁未落地。");
+    }
+
+    // ==================== 反射/断言辅助 ====================
+
+    /** 断言类级 @RequestMapping 含指定路径(值可为 value/path,String[] 形式)。 */
+    private void assertClassMapping(Class<?> clazz, String path, String failMsg) {
+        boolean has = Arrays.stream(clazz.getDeclaredAnnotations())
+                .anyMatch(a -> a instanceof RequestMapping
+                        && Arrays.asList(((RequestMapping) a).value()).contains(path));
+        assertTrue(has, failMsg);
+    }
+
+    /** 断言方法级 @PostMapping 含指定路径。 */
+    private void assertPostMapping(Class<?> clazz, String method, String path, String failMsg) {
+        Method m = findMethod(clazz, method);
+        assertNotNull(m, failMsg + " 方法 " + method + " 不存在。");
+        assertTrue(m.isAnnotationPresent(PostMapping.class)
+                        && Arrays.asList(m.getAnnotation(PostMapping.class).value()).contains(path),
+                failMsg);
+    }
+
+    /** 权限流探针:断言 create/update 处理方法至少暴露一个 @RequestAttribute("role"|"roles") 输入。 */
+    private void assertRoleGate(Class<?> clazz, String method, String failMsg) {
+        Method m = findMethod(clazz, method);
+        assertNotNull(m, failMsg + " 方法 " + method + " 不存在。");
+        boolean hasRoleInput = false;
+        for (Parameter p : m.getParameters()) {
+            for (Annotation a : p.getAnnotations()) {
+                if (a instanceof RequestAttribute) {
+                    String attr = ((RequestAttribute) a).value();
+                    if ("role".equals(attr) || "roles".equals(attr)) {
+                        hasRoleInput = true;
+                        break;
+                    }
+                }
+            }
+            if (hasRoleInput) break;
+        }
+        assertTrue(hasRoleInput, failMsg);
+    }
+
+    private Method findMethod(Class<?> clazz, String name) {
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals(name)) return m;
+        }
+        return null;
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+}