Selaa lähdekoodia

merge(task/S-cfc-223): child.feedback.submit TDD-RED 契约测试 合入 cfclub 主线

Agnes Agent 7 tuntia sitten
vanhempi
sitoutus
95ce3c1984

+ 357 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildFeedbackSubmitContractTest.java

@@ -0,0 +1,357 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.DailyFeedbackController;
+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.lang.reflect.Parameter;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:孩子/家庭成员提交意见反馈(能力键 {@code child.feedback.submit})。
+ *
+ * <p>契约来源(requirements + design):</p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-23「意见反馈(孩子端)」——
+ *       <b>主流程</b>:孩子(不能登录用户,memberType=local)提交反馈(内容/类型)→ 进入客服队列;
+ *       <b>异常/边界</b>:内容适龄过滤;敏感信息脱敏;附件大小限制。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.7 缺口 G-06——
+ *       「无通用意见反馈端点/表/管理端;现有反馈均为专用方向」,修复建议:
+ *       <b>新增 {@code user_feedback} 表 + {@code POST /api/user/feedback/submit}
+ *       (避开 {@code /api/feedback} 前缀冲突)+ 管理端受理</b>。
+ *       现有 {@link DailyFeedbackController}({@code /api/feedback/*},家长→孩子)、
+ *       {@code ActivityFeedbackController}({@code /api/activity/feedback/*},活动评价)、
+ *       {@code HealthReportController.submitFeedback}({@code /api/health/feedback/submit},报告维度)
+ *       均不是「孩子→客服队列」的通用意见反馈通道。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实)</b>:契约面全部缺失——
+ * <ul>
+ *   <li>无 {@code user_feedback} 表(schema.sql 0 命中);</li>
+ *   <li>无 {@code UserFeedback} 实体 / {@code UserFeedbackService} / {@code UserFeedbackController}
+ *       (src/main 0 命中);</li>
+ *   <li>无针对意见反馈的内容适龄过滤 / 敏感信息脱敏 / 附件大小限制。
+ *       (现库内「敏感信息」文案均为文章/图片/语音等审核位点,非反馈通道。)</li>
+ * </ul>
+ * 孩子(不能登录用户)无 userId,反馈须以家庭成员 {@code memberId} 维度承载(同 SC-C-15/C-19 口径)。</p>
+ *
+ * <p><b>测试策略</b>:纯反射结构契约 + schema.sql 文本探测(无 Spring / 无 DB / 无外部服务),
+ * 能编译、能运行、断言失败(红),失败信息可读可贴。GREEN 阶段实现后本测试自动转绿。</p>
+ *
+ * <p><b>RED 预期</b>:下列断言在实现落地前应全部失败(红);其中「正常流-1」为既有
+ * {@code /api/feedback/today} 绿锚点——证明 DailyFeedbackController 方向(家长→孩子)
+ * 与契约要求(孩子提交→客服队列)相反,不作为本能力载体。</p>
+ */
+class ChildFeedbackSubmitContractTest {
+
+    /** 契约建议的表(§4.61.7 G-06):通用意见反馈表 */
+    private static final String FEEDBACK_TABLE = "user_feedback";
+
+    /** 契约建议的端点全路径(避开 /api/feedback 前缀冲突) */
+    private static final String SUBMIT_PATH = "/api/user/feedback/submit";
+
+    /** 孩子(不能登录用户)的家庭成员标识 */
+    private static final Long MEMBER_ID = 88L;
+
+    // ------------------------------------------------------------------
+    // 正常流:通用意见反馈通道(POST /api/user/feedback/submit)三件套
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1(绿锚点):DailyFeedbackController 为家长→孩子方向,非孩子提交通道")
+    void dailyFeedback_isParentToChildDirection() {
+        Class<?> clazz = DailyFeedbackController.class;
+        String classPath = resolveRequestMapping(clazz);
+        boolean hasToday = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if ("/today".equals(mapping.get("path")) && "POST".equals(mapping.get("httpMethod"))) {
+                hasToday = true;
+                break;
+            }
+        }
+        assertNotNull(classPath, "[child.feedback.submit] DailyFeedbackController 应保留 /api/feedback 类映射。");
+        assertTrue(("/api/feedback".equals(classPath) && hasToday),
+                "[child.feedback.submit] DailyFeedbackController 应为 /api/feedback/today(家长→孩子即时反馈),"
+                        + "与人「孩子→客服队列」的意见反馈方向相反,不能作为 child.feedback.submit 载体。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:UserFeedback 实体应存在(涉及 user_feedback 表)")
+    void entity_userFeedback_exists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.entity.UserFeedback");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedback 实体不存在:契约要求通用意见反馈落库 user_feedback"
+                        + "(§4.61.7 G-06;SC-C-23 主流程)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:UserFeedback 实体应承载内容/类型/队列状态")
+    void entity_userFeedback_carriesSumbitFields() {
+        Class<?> clazz = loadClass("com.etotem.cfc.entity.UserFeedback");
+        if (clazz == null) {
+            fail("[child.feedback.submit] UserFeedback 实体不存在,无法验证提交字段(SC-C-23 主流程)。");
+        }
+        for (String required : new String[]{"content", "type", "status", "attachmentUrl"}) {
+            boolean has = false;
+            for (Field f : clazz.getDeclaredFields()) {
+                if (f.getName().equals(required)) {
+                    has = true;
+                    break;
+                }
+            }
+            assertTrue(has,
+                    "[child.feedback.submit] UserFeedback 应含字段 " + required + "(内容/类型/客服队列状态/"
+                            + "附件),契约要点:提交反馈(内容/类型)→ 进入客服队列(SC-C-23)。实现未落地。");
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-4:UserFeedbackService 应存在并提供 submit 入口")
+    void service_userFeedback_serviceAndSubmitExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在:契约要求通用意见反馈提交入口"
+                        + "(§4.61.7 G-06;SC-C-23 主流程)。实现未落地。");
+        boolean hasSubmit = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("submit")) {
+                hasSubmit = true;
+                break;
+            }
+        }
+        assertTrue(hasSubmit,
+                "[child.feedback.submit] UserFeedbackService 应提供 submit(...) 入口(内容/类型 → 客服队列),"
+                        + "接受成员级 memberId 维度(孩子不能登录无 userId;SC-C-23 主流程)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:UserFeedbackController 应存在并映射 POST /api/user/feedback/submit")
+    void controller_userFeedback_submitMappingExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.controller.UserFeedbackController");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackController 不存在:契约要求 POST /api/user/feedback/submit"
+                        + " 意见反馈端点(§4.61.7 G-06,避开 /api/feedback 前缀冲突;SC-C-23 主流程)。实现未落地。");
+        boolean hasSubmit = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if (SUBMIT_PATH.equals(mapping.get("path")) && "POST".equals(mapping.get("httpMethod"))) {
+                hasSubmit = true;
+                break;
+            }
+        }
+        assertTrue(hasSubmit,
+                "[child.feedback.submit] UserFeedbackController 应映射 " + SUBMIT_PATH
+                        + "(POST):提交反馈后记录进入客服队列(SC-C-23 主流程;§4.61.7 G-06)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:内容适龄过滤 / 敏感信息脱敏 / 附件大小限制
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:应提供内容适龄过滤位点(含年龄段位点)")
+    void boundary_ageAppropriatenessFilterExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在,无法验证内容适龄过滤位点(SC-C-23 边界)。");
+
+        boolean hasChildAge = false;
+        boolean hasAgeFilter = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().contains("ageGroup") || f.getName().contains("Age")) {
+                hasChildAge = true;
+            }
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName();
+            if (name.toLowerCase().contains("age") || name.contains("filter")
+                    || name.contains("sanitize") || name.contains("review")) {
+                hasAgeFilter = true;
+                hasChildAge = true;
+            }
+        }
+        assertTrue(hasChildAge,
+                "[child.feedback.submit] UserFeedbackService 应提供适龄过滤位点(如方法/字段含 age/filter),"
+                        + "异常/边界:内容适龄过滤(SC-C-23)。实现未落地。");
+        assertTrue(hasAgeFilter,
+                "[child.feedback.submit] UserFeedbackService 应提供内容安全过滤(filter/sanitize/review),"
+                        + "对反馈内容做适龄/安全把关(SC-C-23 边界)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:应提供敏感信息脱敏位点")
+    void boundary_sensitiveInfoDesensitizationExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在,无法验证敏感信息脱敏位点(SC-C-23 边界)。");
+        boolean hasMask = false;
+        boolean hasDesensitize = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains("mask")
+                    || f.getName().toLowerCase().contains("sensitive")
+                    || f.getName().toLowerCase().contains("desensit")) {
+                hasMask = true;
+            }
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("mask") || name.contains("desensit") || name.contains("pii")) {
+                hasDesensitize = true;
+                hasMask = true;
+            }
+        }
+        assertTrue(hasMask,
+                "[child.feedback.submit] UserFeedbackService 应提供敏感信息脱敏位点(mask/desensitize/"
+                        + "sensitive/pii),异常/边界:敏感信息脱敏(SC-C-23)。实现未落地。");
+        assertTrue(hasDesensitize,
+                "[child.feedback.submit] UserFeedbackService 应提供脱敏处理(mask/desensitize/pii),"
+                        + "反馈内容中的手机号/身份证等敏感信息入库前脱敏(SC-C-23 边界)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:应提供附件大小限制位点")
+    void boundary_attachmentSizeLimitExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在,无法验证附件大小限制位点(SC-C-23 边界)。");
+        boolean hasLimit = false;
+        boolean hasLimitCheck = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains("size")
+                    || f.getName().contains("MAX") || f.getName().contains("limit")) {
+                hasLimit = true;
+            }
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("attachment") || name.contains("size")
+                    || name.contains("limit") || name.contains("check")) {
+                hasLimitCheck = true;
+                hasLimit = true;
+            }
+        }
+        assertTrue(hasLimit,
+                "[child.feedback.submit] UserFeedbackService 应提供附件大小限制位点(size/MAX/limit),"
+                        + "异常/边界:附件大小限制(SC-C-23)。实现未落地。");
+        assertTrue(hasLimitCheck,
+                "[child.feedback.submit] UserFeedbackService 应提供附件校验(attachment/size/limit/check),"
+                        + "超出上限的附件应被拒绝(SC-C-23 边界)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:user_feedback 表应落地 schema.sql 并承载客服队列状态")
+    void schemaSql_hasUserFeedbackTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(FEEDBACK_TABLE),
+                "[child.feedback.submit] schema.sql 缺少 " + FEEDBACK_TABLE
+                        + " 建表:通用意见反馈落库位缺失(§4.61.7 G-06)。实现未落地。");
+        assertTrue(schema.contains("status"),
+                "[child.feedback.submit] " + FEEDBACK_TABLE
+                        + " 应含 status 列承载客服队列状态(提交反馈 → 进入客服队列,SC-C-23 主流程)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:孩子(不能登录用户)由家庭成员代管提交,按 memberId 归属自身
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:提交入口应接受成员级 memberId(孩子不能登录,由家庭成员代管)")
+    void permissionFlow_memberScopedSubmit() {
+        Class<?> clazz = loadClass("com.etotem.cfc.controller.UserFeedbackController");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackController 不存在,无法验证成员级提交归属(SC-C-23 前置)。");
+        boolean hasMemberSubmit = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            for (Parameter p : m.getParameters()) {
+                if (p.getName().equals("memberId")) {
+                    hasMemberSubmit = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(hasMemberSubmit,
+                "[child.feedback.submit] UserFeedbackController 提交入口应接受 memberId 参数:"
+                        + "孩子(不能登录用户)无 userId,意见反馈须以家庭成员 memberId 维度提交,"
+                        + "且应校验 familyId 归属(SC-C-23 前置条件:已登录)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 读取类级 @RequestMapping 的 path 值 */
+    private String resolveRequestMapping(Class<?> clazz) {
+        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);
+            String[] value = rm.value();
+            if (value != null && value.length > 0) {
+                return value[0];
+            }
+        }
+        return null;
+    }
+
+    /** 读取方法级映射 path(@PostMapping/@GetMapping/@RequestMapping) */
+    private Map<String, String> resolveMappingPath(Method m) {
+        Map<String, String> result = new HashMap<>();
+        org.springframework.web.bind.annotation.PostMapping pm =
+                m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+        if (pm != null) {
+            String[] value = pm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "POST");
+            return result;
+        }
+        org.springframework.web.bind.annotation.GetMapping gm =
+                m.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class);
+        if (gm != null) {
+            String[] value = gm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "GET");
+            return result;
+        }
+        org.springframework.web.bind.annotation.RequestMapping rm =
+                m.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
+        if (rm != null) {
+            String[] value = rm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "REQUEST");
+            return result;
+        }
+        return result;
+    }
+
+    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 "";
+        }
+    }
+}