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