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

test(RED): S-cfc-246 ai.coach.reportAnalyze 成长报告智能解读 契约级单测10例全红

- 契约溯源:tests/user-scenarios/应用场景总览.md SC-B-04 + tests/test-cases/butler.md TC-B-04/E/P
- 设计锚点:API_REFERENCE 4.64.4 POST /api/ai/report/analyze(报告翻译人话+五维关联+行动建议)
- 实现现状核实(origin/cfclub):reportAnalyze 端点骨架存在,但契约新增行为未落地
  (三段结构化输出 VO / 报告归属越权403 / 解析中拦截 / 类型不一致 / PII脱敏)
- 10 用例全红(Errors:0 纯断言失败):正常流4(plainSummary/fiveDimAssociations/actionSuggestions/五维一致)
  + 异常流4(无报告请先上传/解析中提示/类型不一致/高敏脱敏)+ 权限流2(越权403)
- 纯 Mockito/反射注入,无 Spring/DB/外部服务(仿 CoachButlerNotificationContractTest)
Sisyphus Agent 1 день назад
Родитель
Сommit
b2b7e11c78

+ 333 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/AiCoachReportAnalyzeContractTest.java

@@ -0,0 +1,333 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.ai.AIChatController;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.service.AIService;
+import com.etotem.cfc.service.FamilyContextService;
+import com.etotem.cfc.service.PortraitService;
+import com.etotem.cfc.service.SelfCheckAnalysisService;
+import com.etotem.cfc.service.TaskParseService;
+import com.etotem.cfc.service.UserService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.RedisTemplate;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:成长报告智能解读(能力键 {@code ai.coach.reportAnalyze},SC-B-04)。
+ *
+ * <p><b>契约来源(TDD-RED 溯源)</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-B-04(成长报告智能解读:
+ *       报告翻译人话 + 五维关联 + 行动建议);{@code tests/test-cases/butler.md}
+ *       TC-B-04 正常流 / TC-B-04-E 异常流 / TC-B-04-P 权限流</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.64.4 ——
+ *       {@code POST /api/ai/report/analyze} 成长报告智能解读(LangGraph 解析;
+ *       不可用时返回「AI服务暂不可用」显式错误)</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(TC-B-04 三类流)</b></p>
+ * <ul>
+ *   <li>正常流(TC-B-04):解读输出应含<b>「通俗解读 + 五维关联 + 行动建议」三段结构</b>,
+ *       五维关联维度与报告实际维度一致;解读引用的数值不超出报告范围(无杜撰指标)</li>
+ *   <li>异常流(TC-B-04-E):无报告提示「请先上传报告」不返回空白解读;报告仍在解析(未入库)
+ *       提示「报告解析中,请稍后」不读半成品;同一孩子两份不同类型报告提示类型不一致不可横向比较;
+ *       输出中姓名/手机号等打码脱敏</li>
+ *   <li>权限流(TC-B-04-P):越权家庭/孩子/规划师请求非本家庭报告解读 → 403,
+ *       不返回报告内容或解读结果</li>
+ * </ul>
+ *
+ * <p><b>实现现状核实(origin/cfclub,2026-11-14)</b>:{@code AIChatController.reportAnalyze}
+ * 端点骨架已存在(参数校验 + FREE Redis 计数 + buildChatInputs + aiService.sendMessage,
+ * 返回自由文本 {@code {answer, conversationId}}),但契约新增行为<b>均未落地</b>:
+ * <ul>
+ *   <li>无结构化三段输出 VO(仅返回自由文本 answer,未解析出通俗解读/五维关联/行动建议)</li>
+ *   <li>无报告归属越权校验({@code FamilyContextService.buildContext} 直查 reportDetail,
+ *       无 canAccessFamily/403 语义;TC-B-04-P 缺失)</li>
+ *   <li>无报告解析中(draftId 草稿未入库)半成品拦截、无报告类型一致性校验、无输出 PII 脱敏
+ *       ({@code TaskParseService.stripTaskMarkers} 仅去标记位)</li>
+ * </ul>
+ * 因此本测试以「行为契约 + 反射结构」断言以上缺失:能编译、能运行、断言失败(红)。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者在 reportAnalyze 成功响应中产出结构化三段
+ * (通俗解读/五维关联/行动建议)并在边界场景返回对应提示、在越权场景返回 403 后,
+ * 本测试相应断言自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 反射注入(仿 {@code CoachButlerNotificationContractTest}),
+ * 无 Spring / 无 DB / 无外部服务。FREE Redis 计数非本键契约点,
+ * 用例统一使用 FAMILY 会员避免触碰 Redis 分支。</p>
+ */
+class AiCoachReportAnalyzeContractTest extends AssessmentPlanContractTestBase {
+
+    private static final Long USER_ID = 1L;
+    private static final String REPORT_ID = "100";
+    private static final String ANSWER_RAW = "孩子的肠道菌群丰度略有下降,建议增加膳食纤维。";
+
+    // ==================== 正常流:解读输出三段结构(TC-B-04) ====================
+
+    @Test
+    @DisplayName("正常流-1:解读输出应含「通俗解读」段")
+    void normalFlow_plainSummarySegment() throws Exception {
+        AIChatController c = newController("FAMILY");
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, paramsReport());
+        assertEqualsCode200(r);
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "[ai.coach.reportAnalyze] 解读成功响应 data 不应为 null");
+        assertTrue(data.containsKey("plainSummary"),
+                "[ai.coach.reportAnalyze] SC-B-04/TC-B-04 契约要求解读输出含「通俗解读」段"
+                        + "(key=plainSummary,把专业指标翻译成人话)。实现现状仅返回自由文本 answer。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:解读输出应含「五维关联」段")
+    void normalFlow_fiveDimAssociationsSegment() throws Exception {
+        AIChatController c = newController("FAMILY");
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, paramsReport());
+        assertEqualsCode200(r);
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "[ai.coach.reportAnalyze] 解读成功响应 data 不应为 null");
+        assertTrue(data.containsKey("fiveDimAssociations"),
+                "[ai.coach.reportAnalyze] SC-B-04/TC-B-04 契约要求解读输出含「五维关联」段"
+                        + "(key=fiveDimAssociations,把报告发现关联到身/心/智/行/富五维)。"
+                        + "实现现状未输出。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:解读输出应含「行动建议」段")
+    void normalFlow_actionSuggestionsSegment() throws Exception {
+        AIChatController c = newController("FAMILY");
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, paramsReport());
+        assertEqualsCode200(r);
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "[ai.coach.reportAnalyze] 解读成功响应 data 不应为 null");
+        assertTrue(data.containsKey("actionSuggestions"),
+                "[ai.coach.reportAnalyze] SC-B-04/TC-B-04 契约要求解读输出含「行动建议」段"
+                        + "(key=actionSuggestions,可被采纳为任务)。实现现状未输出。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:五维关联维度应与报告实际维度一致(身心智行富)")
+    void normalFlow_fiveDimMatchesReportDimensions() throws Exception {
+        AIChatController c = newController("FAMILY");
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, paramsReport());
+        assertEqualsCode200(r);
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "[ai.coach.reportAnalyze] 解读成功响应 data 不应为 null");
+        Object fiveDim = data.get("fiveDimAssociations");
+        assertNotNull(fiveDim, "[ai.coach.reportAnalyze] 五维关联段(fiveDimAssociations)缺失");
+        if (!(fiveDim instanceof Map)) {
+            fail("[ai.coach.reportAnalyze] fiveDimAssociations 应为 Map<维度,关联说明>");
+        }
+        @SuppressWarnings("unchecked")
+        Map<String, Object> dims = (Map<String, Object>) fiveDim;
+        assertTrue(dims.keySet().containsAll(java.util.Arrays.asList("body", "mind", "intellect", "action", "wealth")),
+                "[ai.coach.reportAnalyze] 五维关联维度应覆盖身/心/智/行/富(body/mind/intellect/action/wealth)"
+                        + "且与报告实际维度一致(TC-B-04)。实际=" + dims.keySet());
+    }
+
+    // ==================== 异常流:边界行为(TC-B-04-E) ====================
+
+    @Test
+    @DisplayName("异常流-1:无报告(reportId 不存在/未上传)应提示「请先上传报告」且不返回解读")
+    void exceptionalFlow_missingReport_promptUploadFirst() throws Exception {
+        AIChatController c = newController("FAMILY");
+        // 契约 AC:无报告不解读,提示「请先上传报告」,不返回空白解读。
+        // reportId 指向不存在/未上传的报告时,实现应拒绝而非继续解读。
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, params("解读一下", "999999", null));
+        boolean blocked = r.getCode() != 200
+                && r.getMessage() != null
+                && r.getMessage().contains("上传");
+        assertTrue(blocked,
+                "[ai.coach.reportAnalyze] SC-B-04-E 契约要求:无报告不解读,提示「请先上传报告」"
+                        + "(code!=200 且提示含「上传」)。实现现状对不存在 reportId 仍返回 200 解读。"
+                        + "实际 code=" + r.getCode() + " message=" + r.getMessage());
+    }
+
+    @Test
+    @DisplayName("异常流-2:报告仍在解析(未入库的 draftId)应提示「报告解析中」不读半成品")
+    void exceptionalFlow_reportParsing_promptRetry() throws Exception {
+        AIChatController c = newController("FAMILY");
+        // TC-B-04-E:报告仍在解析(未入库)时发起 → 提示「报告解析中,请稍后」,不读半成品。
+        // 实现须区分「解析中草稿」与「已解析完成草稿」:后者(report-confirm 页)允许解读,
+        // 前者必须拦截;现状 draftId 一律直接 buildContextFromDraft 读 payload → 缺解析状态门禁。
+        Map<String, String> params = new HashMap<>();
+        params.put("query", "解读草稿");
+        params.put("draftId", "500");
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, params);
+        boolean blocked = r.getCode() != 200
+                && r.getMessage() != null
+                && (r.getMessage().contains("解析中") || r.getMessage().contains("稍后"));
+        assertTrue(blocked,
+                "[ai.coach.reportAnalyze] SC-B-04-E 契约要求:报告仍在解析(未入库)时提示"
+                        + "「报告解析中,请稍后」,不读半成品。实现现状 draftId 未校验解析状态,"
+                        + "直接读草稿 payload 继续解读。实际 code=" + r.getCode() + " message=" + r.getMessage());
+    }
+
+    @Test
+    @DisplayName("异常流-3:不同类型报告先后解读应提示类型不一致、不可横向比较")
+    void exceptionalFlow_typeMismatch_notComparable() throws Exception {
+        AIChatController c = newController("FAMILY");
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, paramsReport());
+        assertEqualsCode200(r);
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "[ai.coach.reportAnalyze] 解读成功响应 data 不应为 null");
+        Object warning = data.get("typeWarning");
+        boolean warned = warning instanceof Map
+                && ((Map<?, ?>) warning).containsKey("mismatch")
+                && Boolean.TRUE.equals(((Map<?, ?>) warning).get("mismatch"));
+        assertTrue(warned,
+                "[ai.coach.reportAnalyze] SC-B-04-E 契约要求:同一孩子两份不同类型报告先后解读时"
+                        + "提示类型不一致、不可横向比较(响应应含 typeWarning.mismatch 标记)。"
+                        + "实现现状无报告类型一致性校验。");
+    }
+
+    @Test
+    @DisplayName("异常流-4:输出含姓名/手机号等高敏信息应打码脱敏")
+    void exceptionalFlow_highSensitiveInfo_desensitized() throws Exception {
+        AIChatController c = newController("FAMILY");
+        stubbedAnswer(c, "张三的肠道菌群丰度下降,联系电话 13800138000,可联系主治医生。");
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, paramsReport());
+        assertEqualsCode200(r);
+        Map<String, Object> data = r.getData();
+        assertNotNull(data, "[ai.coach.reportAnalyze] 解读成功响应 data 不应为 null");
+        String answer = String.valueOf(data.getOrDefault("answer", ""));
+        boolean masked = !answer.contains("13800138000") && !answer.contains("张三");
+        assertTrue(masked,
+                "[ai.coach.reportAnalyze] SC-B-04-E 契约要求:输出中姓名/手机号等打码脱敏"
+                        + "(不直接回显高敏信息)。实现现状仅 stripTaskMarkers 去任务标记,无 PII 脱敏。"
+                        + "实际 answer=" + answer);
+    }
+
+    // ==================== 权限流:越权 403(TC-B-04-P) ====================
+
+    @Test
+    @DisplayName("权限流-1:家长请求非本家庭报告解读 → 403,不返回报告内容")
+    void permissionFlow_foreignFamilyReport_403() throws Exception {
+        AIChatController c = newController("FAMILY");
+        // 越权:reportId 属于其它家庭(无归属校验场景下仍被解读即视为越权泄露)
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, params("解读他人报告", "777888", null));
+        boolean forbidden = r.getCode() == 403
+                || (r.getMessage() != null && r.getMessage().contains("无权"));
+        assertTrue(forbidden,
+                "[ai.coach.reportAnalyze] SC-B-04-P 契约要求:家长请求非本家庭(F9)报告解读应 403,"
+                        + "不返回报告内容或解读结果。实现现状 reportAnalyze 无报告归属校验(buildContext"
+                        + "直查 reportDetail),越权 reportId 仍返回 200 解读。"
+                        + "实际 code=" + r.getCode() + " message=" + r.getMessage());
+    }
+
+    @Test
+    @DisplayName("权限流-2:孩子/规划师越权请求非绑定家庭报告 → 403")
+    void permissionFlow_otherRoleForeignReport_403() throws Exception {
+        AIChatController c = newController("FAMILY");
+        // 越权角色(孩子 token 请求家长报告 / 规划师请求非绑定家庭报告)均应被拒
+        Result<Map<String, Object>> r = c.reportAnalyze(USER_ID, params("解读他家庭报告", "555666", null));
+        boolean forbidden = r.getCode() == 403
+                || (r.getMessage() != null && r.getMessage().contains("无权"));
+        assertTrue(forbidden,
+                "[ai.coach.reportAnalyze] SC-B-04-P 契约要求:孩子/规划师越权请求非本家庭报告解读应 403。"
+                        + "实现现状无角色/归属门禁,仍返回 200。"
+                        + "实际 code=" + r.getCode() + " message=" + r.getMessage());
+    }
+
+    // ==================== 辅助构造(仿 CoachButlerNotificationContractTest) ====================
+
+    /**
+     * 构造 reportAnalyze 调用链的 controller:FAMILY 会员(避开 FREE Redis 分支),
+     * mock 掉 buildChatInputs 所需的上下文/画像/自检/记忆,sendMessage 返回契约内 answer。
+     */
+    private AIChatController newController(String memberLevel) throws Exception {
+        AIChatController c = new AIChatController();
+
+        UserService userSvc = mock(UserService.class);
+        User u = new User();
+        u.setMemberLevel(memberLevel);
+        when(userSvc.getUserInfo(anyLong())).thenReturn(u);
+        inject(c, "userService", userSvc);
+
+        FamilyContextService ctxSvc = mock(FamilyContextService.class);
+        when(ctxSvc.buildContext(anyLong(), anyLong())).thenReturn(new LinkedHashMap<String, Object>());
+        when(ctxSvc.buildContext(anyLong())).thenReturn(new LinkedHashMap<String, Object>());
+        when(ctxSvc.buildContext(anyLong(), anyLong(), anyLong())).thenReturn(new LinkedHashMap<String, Object>());
+        when(ctxSvc.buildContextFromDraft(anyLong(), anyLong())).thenReturn(new LinkedHashMap<String, Object>());
+        inject(c, "familyContextService", ctxSvc);
+
+        PortraitService portraitSvc = mock(PortraitService.class);
+        when(portraitSvc.buildPortraitPrompt(anyLong(), any())).thenReturn("");
+        inject(c, "portraitService", portraitSvc);
+
+        SelfCheckAnalysisService selfCheckSvc = mock(SelfCheckAnalysisService.class);
+        when(selfCheckSvc.buildLatestSummary(anyLong(), any())).thenReturn(null);
+        inject(c, "selfCheckAnalysisService", selfCheckSvc);
+
+        AIService aiSvc = mock(AIService.class);
+        // buildChatInputs 末尾记忆层注入:原样返回 inputs,避免后续 NPE
+        when(aiSvc.enrichInputsWithMemory(anyLong(), any(), any()))
+                .thenAnswer(inv -> inv.getArgument(2));
+        // 默认 AI 响应成功(answer + conversationId),使断言聚焦契约新增行为而非链路桩件
+        Map<String, Object> aiResp = new LinkedHashMap<>();
+        aiResp.put("answer", ANSWER_RAW);
+        aiResp.put("conversationId", "conv-contract");
+        when(aiSvc.sendMessage(anyString(), anyString(), any(), any(Map.class))).thenReturn(aiResp);
+        inject(c, "aiService", aiSvc);
+
+        TaskParseService tpSvc = mock(TaskParseService.class);
+        when(tpSvc.stripTaskMarkers(anyString())).thenAnswer(inv -> inv.getArgument(0));
+        inject(c, "taskParseService", tpSvc);
+
+        inject(c, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
+        inject(c, "redisTemplate", mock(RedisTemplate.class));
+        return c;
+    }
+
+    /** 覆盖 sendMessage 返回指定 answer(默认 ANSWER_RAW) */
+    private void stubbedAnswer(AIChatController c, String answer) throws Exception {
+        com.etotem.cfc.service.AIService aiSvc =
+                (com.etotem.cfc.service.AIService) field(c, "aiService");
+        Map<String, Object> resp = new LinkedHashMap<>();
+        resp.put("answer", answer);
+        resp.put("conversationId", "conv-contract");
+        when(aiSvc.sendMessage(anyString(), anyString(), any(), any(Map.class))).thenReturn(resp);
+    }
+
+    private Object field(Object target, String fieldName) throws Exception {
+        java.lang.reflect.Field f = target.getClass().getDeclaredField(fieldName);
+        f.setAccessible(true);
+        return f.get(target);
+    }
+
+    private Map<String, String> paramsReport() {
+        return params("请帮我用通俗语言解读这份报告,关联五维并给出行动建议", REPORT_ID, null);
+    }
+
+    private Map<String, String> params(String query, String reportId, String draftId) {
+        Map<String, String> p = new HashMap<>();
+        p.put("query", query);
+        if (reportId != null) {
+            p.put("reportId", reportId);
+        }
+        if (draftId != null) {
+            p.put("draftId", draftId);
+        }
+        return p;
+    }
+
+    private void assertEqualsCode200(Result<?> r) {
+        if (r.getCode() != 200) {
+            fail("[ai.coach.reportAnalyze] 期望 200,实际 code=" + r.getCode()
+                    + " message=" + r.getMessage());
+        }
+    }
+}