|
|
@@ -0,0 +1,212 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.controller.growth.GrowthPlanController;
|
|
|
+import com.etotem.cfc.entity.GrowthPlan;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.GrowthPlanMapper;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.GrowthPlanService;
|
|
|
+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 java.lang.annotation.Annotation;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.lang.reflect.Parameter;
|
|
|
+import java.util.Arrays;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+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.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:能力键 {@code plan.confirm.submit}(SC-P-44 确认方案 · SC-P-45 方案直接发布)。
|
|
|
+ *
|
|
|
+ * <p><b>契约锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code docs/tdd/orchestration/sc-key-map.md} SC-P-44 / SC-P-45(家长端方案生命周期),
|
|
|
+ * 两行能力键均为 {@code plan.confirm.submit},状态 RED / 未通过,任务 S-cfc-281:
|
|
|
+ * SC-P-44 = 家长确认/送审,generated→reviewed 确认态流转;SC-P-45 = 方案直接发布(应直接 active)。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.6 ——
|
|
|
+ * {@code GrowthPlanController /api/growth/plan/*}(create 创建 / {@code /{id}/review} 复盘)。
|
|
|
+ * 状态机参照 {@code PlanReviewController}(TaskPlanInstance:generated→reviewed→active,
|
|
|
+ * 「方案状态异常」守卫)。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>实现现状(2026-11-14 实测 origin/cfclub @64ac74d1f)</b>:
|
|
|
+ * {@code GrowthPlanController} 无 confirm / submit / 发布端点;{@code /{id}/review} 无 role/roles
|
|
|
+ * 请求属性(与同域 {@code listPlans} 的 RolesUtil.hasTeacherRole 门禁不同构);
|
|
|
+ * {@code GrowthPlanService} 无 {@code confirmPlan} 方法,{@code createPlan} 默认 {@code status="active"}
|
|
|
+ * (违背「未确认不可激活」),{@code reviewPlan} 仅 reviewCount+1、无 generated→reviewed 状态流转、
|
|
|
+ * 无越状态守卫;{@code GrowthPlan.status} 注释仅 draft/active/completed/expired(无 generated/reviewed 态)。
|
|
|
+ * 本测试以这些缺口为 RED 锚点:断言在 GREEN 阶段补齐前失败(红)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯 Mockito / 反射 / 无 Spring / 无 DB / 无外部服务;断言不依赖真实外部服务。
|
|
|
+ * 六例 RED:正常流(confirm / submit 端点载体探针、confirmPlan 方法载体探针)、行为(未确认不可激活、
|
|
|
+ * reviewPlan 状态流转 reviewed、越状态守卫抛异常)、权限流(reviewPlan 角色门禁探针)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:6/6 失败——端点/方法缺失经反射探针(assertNotNull 失败);行为断言
|
|
|
+ * (status==active、review 后未流转、未抛异常)与当前实现不符而失败;权限门禁缺失经 assertRoleGate 失败。
|
|
|
+ * 断言原文均含能力键前缀 {@code [plan.confirm.submit]} 供 GREEN 阶段实现者贴原文。</p>
|
|
|
+ */
|
|
|
+class PlanConfirmSubmitContractTest extends AssessmentPlanContractTestBase {
|
|
|
+
|
|
|
+ // ==================== 正常流-1:确认 / 发布端点载体(RED:当前无 confirm/submit 端点) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-44 正常:GrowthPlanController 应暴露确认方案端点(confirmPlan/confirm,POST)——当前未落地")
|
|
|
+ void controller_confirm_endpointCarrier() {
|
|
|
+ Method m = findMethodByNames(GrowthPlanController.class, "confirmPlan", "confirm");
|
|
|
+ assertNotNull(m,
|
|
|
+ "[plan.confirm.submit] GrowthPlanController(/api/growth/plan/*)未暴露确认方案端点"
|
|
|
+ + "(confirmPlan/confirm,POST,SC-P-44 家长确认方案):"
|
|
|
+ + "SC-P-44/45 未落地 → 当前实现缺确认端点。");
|
|
|
+ assertTrue(m.isAnnotationPresent(PostMapping.class),
|
|
|
+ "[plan.confirm.submit] confirmPlan 应为 POST 映射(框架统一 POST 约定,禁止 Get/Put/Delete)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-45 正常:GrowthPlanController 应暴露方案发布端点(submitPlan/publishPlan,POST)——当前未落地")
|
|
|
+ void controller_submit_endpointCarrier() {
|
|
|
+ Method m = findMethodByNames(GrowthPlanController.class, "submitPlan", "publishPlan", "submit");
|
|
|
+ assertNotNull(m,
|
|
|
+ "[plan.confirm.submit] GrowthPlanController(/api/growth/plan/*)未暴露方案发布端点"
|
|
|
+ + "(submitPlan/publishPlan,POST,SC-P-45 方案直接发布):"
|
|
|
+ + "SC-P-45 未落地 → 当前实现缺发布端点。");
|
|
|
+ assertTrue(m.isAnnotationPresent(PostMapping.class),
|
|
|
+ "[plan.confirm.submit] 发布端点应为 POST 映射(框架统一 POST 约定,禁止 Get/Put/Delete)。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 正常流-2:confirmPlan 方法载体(RED:当前无) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-44/45 正常:GrowthPlanService 应含 confirmPlan 方法(确认幂等)——当前未落地")
|
|
|
+ void service_confirmPlan_methodCarrier() {
|
|
|
+ Method m = findMethod(GrowthPlanService.class, "confirmPlan");
|
|
|
+ assertNotNull(m,
|
|
|
+ "[plan.confirm.submit] GrowthPlanService 未含 confirmPlan 方法"
|
|
|
+ + "(SC-P-44/45 确认/发布方案,确认幂等):SC-P-44/45 未落地 → 当前缺该方法。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 行为:未确认不可激活(RED:createPlan 默认 active) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-44 行为:新建方案(未确认)不应直接激活为 active——当前默认 active")
|
|
|
+ void behavior_create_unconfirmed_notActive() throws Exception {
|
|
|
+ GrowthPlanService svc = new GrowthPlanService();
|
|
|
+ inject(svc, "growthPlanMapper", mock(GrowthPlanMapper.class));
|
|
|
+ inject(svc, "familyMemberMapper", mock(FamilyMemberMapper.class));
|
|
|
+ inject(svc, "userMapper", mock(UserMapper.class));
|
|
|
+
|
|
|
+ GrowthPlan plan = new GrowthPlan();
|
|
|
+ plan.setMemberId(10L);
|
|
|
+ plan.setPlanTitle("SC-P-44 确认方案契约");
|
|
|
+ svc.createPlan(plan);
|
|
|
+
|
|
|
+ assertNotEquals("active", plan.getStatus(),
|
|
|
+ "[plan.confirm.submit] 新建方案(未确认)不应直接激活为 active:"
|
|
|
+ + "按 SC-P-44/45 状态机应先处于待确认态(generated),家长确认/审核通过后才流转为 active。"
|
|
|
+ + "当前 createPlan 默认 status=active → 违背「未确认不可激活」。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 行为:reviewPlan 状态流转(RED:仅 reviewCount+1,无流转) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-44 行为:reviewPlan 审核通过后状态应流转为 reviewed(generated→reviewed→active)——当前未流转")
|
|
|
+ void behavior_review_transitionsToReviewed() throws Exception {
|
|
|
+ GrowthPlanService svc = new GrowthPlanService();
|
|
|
+ GrowthPlanMapper mapper = mock(GrowthPlanMapper.class);
|
|
|
+ inject(svc, "growthPlanMapper", mapper);
|
|
|
+ inject(svc, "familyMemberMapper", mock(FamilyMemberMapper.class));
|
|
|
+ inject(svc, "userMapper", mock(UserMapper.class));
|
|
|
+
|
|
|
+ GrowthPlan plan = new GrowthPlan();
|
|
|
+ plan.setId(501L);
|
|
|
+ plan.setStatus("generated");
|
|
|
+ when(mapper.selectById(501L)).thenReturn(plan);
|
|
|
+ when(mapper.updateById(any(GrowthPlan.class))).thenReturn(1);
|
|
|
+
|
|
|
+ svc.reviewPlan(501L);
|
|
|
+
|
|
|
+ assertEquals("reviewed", plan.getStatus(),
|
|
|
+ "[plan.confirm.submit] reviewPlan 审核通过后方案状态应流转为 reviewed"
|
|
|
+ + "(generated→reviewed→active,参照 PlanReviewController 状态机):"
|
|
|
+ + "当前实现未流转,仅 reviewCount+1。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 行为:越状态守卫(RED:任意状态均可 review 通过) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-P-44 异常:非 generated 状态调 reviewPlan 应抛 IllegalStateException(状态守卫)——当前无守卫")
|
|
|
+ void behavior_review_wrongState_throws() throws Exception {
|
|
|
+ GrowthPlanService svc = new GrowthPlanService();
|
|
|
+ GrowthPlanMapper mapper = mock(GrowthPlanMapper.class);
|
|
|
+ inject(svc, "growthPlanMapper", mapper);
|
|
|
+ inject(svc, "familyMemberMapper", mock(FamilyMemberMapper.class));
|
|
|
+ inject(svc, "userMapper", mock(UserMapper.class));
|
|
|
+
|
|
|
+ GrowthPlan plan = new GrowthPlan();
|
|
|
+ plan.setId(502L);
|
|
|
+ plan.setStatus("active");
|
|
|
+ when(mapper.selectById(502L)).thenReturn(plan);
|
|
|
+
|
|
|
+ assertThrows(IllegalStateException.class, () -> svc.reviewPlan(502L),
|
|
|
+ "[plan.confirm.submit] 非待审核状态(如 active)调 reviewPlan 应拒绝:"
|
|
|
+ + "按 SC-P-44 状态机仅 generated 可审核,越状态应抛异常"
|
|
|
+ + "(参照 PlanReviewController「方案状态异常」守卫):"
|
|
|
+ + "当前无状态守卫,任意状态均 reviewCount+1 通过。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 权限流:reviewPlan 角色门禁(RED:无 role/roles 输入) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("权限流:GrowthPlanController.reviewPlan 应暴露 role/roles 输入(teacher/admin 门禁)——当前未落地")
|
|
|
+ void permission_reviewPlan_roleGate() {
|
|
|
+ assertRoleGate(GrowthPlanController.class, "reviewPlan",
|
|
|
+ "[plan.confirm.submit] GrowthPlanController.reviewPlan(POST /api/growth/plan/{id}/review)"
|
|
|
+ + "未暴露 role/roles 请求属性:规划师/家长确认审核方案应校验 teacher/admin 角色"
|
|
|
+ + "(参照同域 listPlans 的 RolesUtil.hasTeacherRole 门禁)。角色门禁未落地。");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 反射/断言辅助 ====================
|
|
|
+
|
|
|
+ /** 按多个候选名查找方法(按序匹配第一个存在者),用于端点载体探针。 */
|
|
|
+ private Method findMethodByNames(Class<?> clazz, String... names) {
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
+ for (String n : names) {
|
|
|
+ if (m.getName().equals(n)) return m;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Method findMethod(Class<?> clazz, String name) {
|
|
|
+ return findMethodByNames(clazz, name);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 权限流探针:断言处理方法至少暴露一个 @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);
|
|
|
+ }
|
|
|
+}
|