|
|
@@ -0,0 +1,293 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.controller.ContentFilterController;
|
|
|
+import com.etotem.cfc.controller.HealthPlanController;
|
|
|
+import com.etotem.cfc.controller.admin.ReviewCenterController;
|
|
|
+import com.etotem.cfc.controller.guide.GuidePlanReviewController;
|
|
|
+import com.etotem.cfc.controller.guide.PlanReviewController;
|
|
|
+import com.etotem.cfc.entity.HealthPlan;
|
|
|
+import com.etotem.cfc.entity.Task;
|
|
|
+import com.etotem.cfc.mapper.HealthPlanMapper;
|
|
|
+import com.etotem.cfc.mapper.TaskMapper;
|
|
|
+import com.etotem.cfc.service.WechatService;
|
|
|
+import com.etotem.cfc.service.api.WechatServiceInterface;
|
|
|
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
|
|
|
+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.RequestMapping;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+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.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测(TDD-RED):能力键 {@code teacher.plan.review} ——
|
|
|
+ * SC-T-04 方案审核列表+批准、SC-T-28 AI 方案内容安全复核。
|
|
|
+ *
|
|
|
+ * <p><b>契约锚点</b></p>
|
|
|
+ * <ul>
|
|
|
+ * <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-T-04(规划师查看待审核方案列表 →
|
|
|
+ * 批准/驳回,状态流转受守卫)、SC-T-28(AI 生成方案 → <b>敏感词/合规检查</b> →
|
|
|
+ * 放行/驳回;边界:<b>命中即拦截</b>)。</li>
|
|
|
+ * <li>设计:{@code docs/superpowers/specs/2026-11-07-planner-zone-contract.md}
|
|
|
+ * §2 端点核验表(SC-T-04 / SC-T-28)、§3 能力键 {@code teacher.plan.review} →</li>
|
|
|
+ * <li>API:{@code docs/superpowers/api/API_REFERENCE.md} §4.57 SC-T-04 / SC-T-28 行、
|
|
|
+ * §4.60.13。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p><b>本类为 RED 契约(预期失败)</b>:截至 2026-11-13 origin/cfclub,
|
|
|
+ * 审核链路({@code GuidePlanReviewController.approvePlan} → {@code HealthPlanServiceImpl
|
|
|
+ * .approveAndPublish}、{@code HealthPlanController /pending-review/approve})
|
|
|
+ * <b>缺少 AI 方案内容安全复核</b>:既没有引用现成的 {@link WechatServiceInterface#checkText(String)}
|
|
|
+ * 内容安全原语(该原语目前只接在 {@code MediaController} / {@code AdminArticleController}),
|
|
|
+ * {@code ContentFilterController} 也只有 {@code POST /api/content/by-tags},没有方案内容复核端点。
|
|
|
+ * 因此结构探针与行为探针均失败(红)。待实现补齐后应转绿。</p>
|
|
|
+ *
|
|
|
+ * <p><b>已绿的契约锚点</b>(现有 {@code PlanReviewTeacherContractTest} 已覆盖 SC-T-04 生命周期,
|
|
|
+ * 本类只补单一能力键视角的端点映射证据,不重复实现、不修改 src/main)。</p>
|
|
|
+ *
|
|
|
+ * <p><b>测试范围</b>:纯反射 + Mockito / 无 Spring 上下文 / 无 DB / 无外部服务。</p>
|
|
|
+ */
|
|
|
+class PlanContentSafetyReviewContractTest extends AssessmentPlanContractTestBase {
|
|
|
+
|
|
|
+ static {
|
|
|
+ initTable(HealthPlan.class);
|
|
|
+ initTable(Task.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 已绿锚点:端点映射(API_REFERENCE §4.57 SC-T-04 / §4.60.13) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-04 端点:GuidePlanReviewController 待审核列表 / 批准 / 驳回(family 路径)")
|
|
|
+ void endpointMapping_guidePlanReview_pendingApproveReject() {
|
|
|
+ Class<GuidePlanReviewController> clazz = GuidePlanReviewController.class;
|
|
|
+
|
|
|
+ assertTrue(hasClassMapping(clazz, "/api/guide/families/{familyId}/plans"),
|
|
|
+ "GuidePlanReviewController 类级映射应为 /api/guide/families/{familyId}/plans");
|
|
|
+
|
|
|
+ assertTrue(hasPostMapping(clazz, "listPendingPlans", "/pending"),
|
|
|
+ "listPendingPlans 应为 POST /pending(待审核列表)");
|
|
|
+ assertTrue(hasPostMapping(clazz, "approvePlan", "/{planId}/approve"),
|
|
|
+ "approvePlan 应为 POST /{planId}/approve(批准)");
|
|
|
+ assertTrue(hasPostMapping(clazz, "rejectPlan", "/{planId}/reject"),
|
|
|
+ "rejectPlan 应为 POST /{planId}/reject(驳回)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-04 端点:PlanReviewController 方案审核 POST /api/guide/plans/review")
|
|
|
+ void endpointMapping_planReview_review() {
|
|
|
+ Class<PlanReviewController> clazz = PlanReviewController.class;
|
|
|
+
|
|
|
+ assertTrue(hasClassMapping(clazz, "/api/guide/plans"),
|
|
|
+ "PlanReviewController 类级映射应为 /api/guide/plans");
|
|
|
+ assertTrue(hasPostMapping(clazz, "reviewPlan", "/review"),
|
|
|
+ "reviewPlan 应为 POST /review(批准/驳回)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-04 端点:ReviewCenterController 审核中心汇总 POST /api/admin/review-center/summary")
|
|
|
+ void endpointMapping_reviewCenter_summary() {
|
|
|
+ Class<ReviewCenterController> clazz = ReviewCenterController.class;
|
|
|
+
|
|
|
+ assertTrue(hasClassMapping(clazz, "/api/admin/review-center"),
|
|
|
+ "ReviewCenterController 类级映射应为 /api/admin/review-center");
|
|
|
+ assertTrue(hasPostMapping(clazz, "summary", "/summary"),
|
|
|
+ "summary 应为 POST /summary(审核中心计数)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 已绿锚点:待复核内容载体 / 现有安全原语 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-28 载体:HealthPlan 持有 planContent / planJson(AI 方案正文,复核对象)")
|
|
|
+ void contract_healthPlanContentFields() throws Exception {
|
|
|
+ assertNotNull(HealthPlan.class.getDeclaredField("planContent"),
|
|
|
+ "HealthPlan 应持有 planContent(AI 生成的方案正文)");
|
|
|
+ assertNotNull(HealthPlan.class.getDeclaredField("planJson"),
|
|
|
+ "HealthPlan 应持有 planJson(AI 生成的方案结构)");
|
|
|
+ assertTrue("health_plans".equals(tableName(HealthPlan.class)),
|
|
|
+ "HealthPlan 应映射 health_plans 表");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-28 原语:WechatServiceInterface.checkText(String) 可用于方案内容复核")
|
|
|
+ void contract_wechatCheckTextPrimitive() throws Exception {
|
|
|
+ Method m = WechatServiceInterface.class.getMethod("checkText", String.class);
|
|
|
+ assertEquals(boolean.class, m.getReturnType(), "checkText 应返回 boolean(true=安全)");
|
|
|
+ assertTrue(WechatServiceInterface.class.isAssignableFrom(WechatService.class),
|
|
|
+ "WechatService 应实现 WechatServiceInterface,可复用于方案内容复核");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED 探针 A:审核链路缺少内容安全依赖 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-28 RED:health_plans 审核链路必须接入内容安全依赖(checkText 能力)")
|
|
|
+ void structure_approveChain_hasContentSafetyDependency() {
|
|
|
+ Class<?>[] approveChain = new Class<?>[]{
|
|
|
+ HealthPlanServiceImpl.class,
|
|
|
+ GuidePlanReviewController.class,
|
|
|
+ HealthPlanController.class
|
|
|
+ };
|
|
|
+
|
|
|
+ StringBuilder missing = new StringBuilder();
|
|
|
+ boolean found = false;
|
|
|
+ for (Class<?> clazz : approveChain) {
|
|
|
+ if (declaresContentSafetyDependency(clazz)) {
|
|
|
+ found = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ missing.append(clazz.getSimpleName()).append(" ");
|
|
|
+ }
|
|
|
+
|
|
|
+ assertTrue(found,
|
|
|
+ "SC-T-28:AI 方案审核链路必须接入内容安全复核(依赖类型需暴露 checkText(String)),"
|
|
|
+ + "当前缺失于:" + missing.toString().trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED 探针 B:缺少方案内容复核端点 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-28 RED:ContentFilterController 必须提供方案内容安全复核端点(而非仅 /by-tags)")
|
|
|
+ void structure_contentFilter_hasPlanSafetyEndpoint() {
|
|
|
+ Class<ContentFilterController> clazz = ContentFilterController.class;
|
|
|
+
|
|
|
+ boolean hasPlanSafetyEndpoint = Arrays.stream(clazz.getDeclaredMethods())
|
|
|
+ .filter(m -> m.isAnnotationPresent(PostMapping.class))
|
|
|
+ .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
|
|
|
+ .anyMatch(path -> path.toLowerCase().contains("plan"));
|
|
|
+
|
|
|
+ assertTrue(hasPlanSafetyEndpoint,
|
|
|
+ "SC-T-28:方案内容安全复核需要一个可调用端点(如 POST /api/content/plan-safety),"
|
|
|
+ + "当前 ContentFilterController 仅有 " + postPaths(clazz));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED 探针 C:命中即拦截(行为) ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-28 RED:approveAndPublish 命中内容安全 → 拦截发布,不落库")
|
|
|
+ void behavior_approveAndPublish_sensitiveContent_blocked() throws Exception {
|
|
|
+ HealthPlanMapper planMapper = mock(HealthPlanMapper.class);
|
|
|
+ TaskMapper taskMapper = mock(TaskMapper.class);
|
|
|
+
|
|
|
+ HealthPlan plan = healthPlan(901L, "pending_review",
|
|
|
+ "祖传秘方,包治百病,七天根治自闭症,无需运动与饮食干预。");
|
|
|
+ when(planMapper.selectById(901L)).thenReturn(plan);
|
|
|
+
|
|
|
+ HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
|
|
|
+ inject(svc, "healthPlanMapper", planMapper);
|
|
|
+ inject(svc, "taskMapper", taskMapper);
|
|
|
+
|
|
|
+ try {
|
|
|
+ svc.approveAndPublish(901L, 7L, "同意");
|
|
|
+ } catch (RuntimeException expected) {
|
|
|
+ // 命中即拦截:允许以业务异常方式拒绝发布
|
|
|
+ }
|
|
|
+
|
|
|
+ assertFalse("published".equals(plan.getStatus()),
|
|
|
+ "SC-T-28 边界「命中即拦截」:内容安全复核未通过的方案不得置为 published");
|
|
|
+ verify(planMapper, never()).updateById(any(HealthPlan.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== RED 探针 D:审核入口不得在复核前变更状态 ====================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-T-28 RED:GuidePlanReviewController.approvePlan 链路需在发布前完成复核(状态不变更)")
|
|
|
+ void behavior_guidePlanReviewApprove_doesNotPublishBeforeSafetyCheck() throws Exception {
|
|
|
+ HealthPlanMapper planMapper = mock(HealthPlanMapper.class);
|
|
|
+ HealthPlan plan = healthPlan(902L, "pending_review", "无资质医疗建议:停用处方药改用本方案。");
|
|
|
+ when(planMapper.selectById(902L)).thenReturn(plan);
|
|
|
+
|
|
|
+ HealthPlanServiceImpl svc = new HealthPlanServiceImpl();
|
|
|
+ inject(svc, "healthPlanMapper", planMapper);
|
|
|
+ inject(svc, "taskMapper", mock(TaskMapper.class));
|
|
|
+
|
|
|
+ try {
|
|
|
+ svc.approveAndPublish(902L, 7L, null);
|
|
|
+ } catch (RuntimeException expected) {
|
|
|
+ // 同上:拦截即符合契约
|
|
|
+ }
|
|
|
+
|
|
|
+ // 未复核不得发布;已发布说明审核链路跳过了内容安全闸门
|
|
|
+ assertEquals("pending_review", plan.getStatus(),
|
|
|
+ "SC-T-28:审核链路必须先做内容安全复核,未通过前 plan 状态应保持 pending_review");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== helpers ====================
|
|
|
+
|
|
|
+ /** 类级 @RequestMapping 是否包含指定路径 */
|
|
|
+ private static boolean hasClassMapping(Class<?> clazz, String expected) {
|
|
|
+ return Arrays.stream(clazz.getDeclaredAnnotations())
|
|
|
+ .filter(a -> a instanceof RequestMapping)
|
|
|
+ .flatMap(a -> Arrays.stream(((RequestMapping) a).value()))
|
|
|
+ .anyMatch(expected::equals);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 指定方法是否为 POST + 指定路径 */
|
|
|
+ private static boolean hasPostMapping(Class<?> clazz, String methodName, String expectedPath) {
|
|
|
+ return Arrays.stream(clazz.getDeclaredMethods())
|
|
|
+ .filter(m -> m.getName().equals(methodName))
|
|
|
+ .filter(m -> m.isAnnotationPresent(PostMapping.class))
|
|
|
+ .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
|
|
|
+ .anyMatch(expectedPath::equals);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 收集类上所有 POST 路径,用于失败信息可读性 */
|
|
|
+ private static String postPaths(Class<?> clazz) {
|
|
|
+ String[] paths = Arrays.stream(clazz.getDeclaredMethods())
|
|
|
+ .filter(m -> m.isAnnotationPresent(PostMapping.class))
|
|
|
+ .flatMap(m -> Arrays.stream(m.getAnnotation(PostMapping.class).value()))
|
|
|
+ .toArray(String[]::new);
|
|
|
+ return Arrays.toString(paths);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 沿类层次查找字段,判断是否存在类型暴露 checkText(String) 的依赖(内容安全闸门) */
|
|
|
+ private static boolean declaresContentSafetyDependency(Class<?> clazz) {
|
|
|
+ for (Class<?> c = clazz; c != null && c != Object.class; c = c.getSuperclass()) {
|
|
|
+ for (Field f : c.getDeclaredFields()) {
|
|
|
+ if (exposesCheckText(f.getType())) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 类型(接口/实现类)是否暴露 checkText(String) */
|
|
|
+ private static boolean exposesCheckText(Class<?> type) {
|
|
|
+ try {
|
|
|
+ type.getMethod("checkText", String.class);
|
|
|
+ return true;
|
|
|
+ } catch (NoSuchMethodException e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String tableName(Class<?> clazz) {
|
|
|
+ com.baomidou.mybatisplus.annotation.TableName annotation =
|
|
|
+ clazz.getAnnotation(com.baomidou.mybatisplus.annotation.TableName.class);
|
|
|
+ return annotation == null ? null : annotation.value();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static HealthPlan healthPlan(Long id, String status, String planContent) {
|
|
|
+ HealthPlan p = new HealthPlan();
|
|
|
+ p.setId(id);
|
|
|
+ p.setFamilyId(7L);
|
|
|
+ p.setStatus(status);
|
|
|
+ p.setPlanContent(planContent);
|
|
|
+ p.setPlanJson("{\"sections\":[]}");
|
|
|
+ p.setCreatedAt(new Date());
|
|
|
+ p.setUpdatedAt(new Date());
|
|
|
+ return p;
|
|
|
+ }
|
|
|
+}
|