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