|
|
@@ -0,0 +1,714 @@
|
|
|
+package com.etotem.cfc.unit;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.controller.ai.AIBehaviorController;
|
|
|
+import com.etotem.cfc.controller.ai.AIChatController;
|
|
|
+import com.etotem.cfc.dto.AiGateResult;
|
|
|
+import com.etotem.cfc.enums.AiGateStatus;
|
|
|
+import com.etotem.cfc.service.AIService;
|
|
|
+import com.etotem.cfc.service.AiGateway;
|
|
|
+import com.etotem.cfc.service.FamilyContextService;
|
|
|
+import com.etotem.cfc.service.MembershipService;
|
|
|
+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 com.etotem.cfc.service.GrowthTaskService;
|
|
|
+import com.etotem.cfc.service.ChatMirrorService;
|
|
|
+import com.etotem.cfc.service.HealthAnalysisService;
|
|
|
+import com.etotem.cfc.service.RecommendationService;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.lang.reflect.Field;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
|
+import static org.mockito.ArgumentMatchers.anyLong;
|
|
|
+import static org.mockito.ArgumentMatchers.anyMap;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.nullable;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 契约级单测:AI 成长教练咨询对话与报告追问(能力键 {@code ai.coach.consult})。
|
|
|
+ *
|
|
|
+ * <p>契约溯源(v0.7,O-8 归一后 241 SC):
|
|
|
+ * <ul>
|
|
|
+ * <li>SC-B-01:成长咨询对话(问题解答·咨询)—— AIChatController /api/ai/chat/send 多轮上下文、
|
|
|
+ * /api/ai/chat/conversations、/api/ai/chat/messages、/api/ai/chat/conversations/{id}/delete</li>
|
|
|
+ * <li>SC-B-11:报告细节追问(问题解答·追问)—— 同 chat/send 追问上下文、report/analyze 注入报告上下文、
|
|
|
+ * conversations/messages 会话历史</li>
|
|
|
+ * <li>AIBehaviorController /api/ai/behavior/summary 行为摘要</li>
|
|
|
+ * <li>LangGraph 问答生成:AIService.sendMessage → AiGateway.chat</li>
|
|
|
+ * </ul>
|
|
|
+ * </p>
|
|
|
+ *
|
|
|
+ * <p>三类流(纯 Mockito / 无 Spring / 无 DB / 无外部服务):
|
|
|
+ * <ul>
|
|
|
+ * <li>正常流:咨询对话/报告追问 200、多轮 conversationId、会话历史、行为摘要</li>
|
|
|
+ * <li>异常流:空消息、超长文本、空 conversationId、报告上下文缺失、越权报告</li>
|
|
|
+ * <li>权限流:非会员 46003、会员过期 46004、孩子端 403、越权家庭 403</li>
|
|
|
+ * </ul>
|
|
|
+ * </p>
|
|
|
+ *
|
|
|
+ * <p><b>RED 预期</b>:以下契约行为在当前实现中缺失/默认值未实现,断言应失败(红):
|
|
|
+ * <ul>
|
|
|
+ * <li>空消息应返回 400(当前实现返回 500「消息不能为空」)</li>
|
|
|
+ * <li>超长文本(>8000 字)应返回 400 长度上限(当前实现无长度校验)</li>
|
|
|
+ * <li>chat/messages 缺 conversationId 应返回 400(当前实现返回 500)</li>
|
|
|
+ * <li>report/analyze 缺 reportId/draftId 应返回 400(当前实现返回 500)</li>
|
|
|
+ * <li>越权查询他人报告应拒绝且不泄漏到 LangGraph(当前实现无家庭归属校验,上下文缺失时 NPE)</li>
|
|
|
+ * </ul>
|
|
|
+ * 以上 5 处为 GREEN 阶段需补齐的契约行为;其余(正常流 200/多轮/会话历史/行为摘要接线、
|
|
|
+ * 非会员 46003/会员过期 46004)当前实现已具备,应保持绿(意外全绿仅限这些已落地项)。
|
|
|
+ * </p>
|
|
|
+ */
|
|
|
+class AICoachConsultContractTest {
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // Test constants
|
|
|
+ // ============================================================
|
|
|
+ private static final Long TEST_USER_ID = 1L;
|
|
|
+ private static final Long TEST_MEMBER_ID = 10L;
|
|
|
+ private static final String TEST_CONVERSATION_ID = "conv-123";
|
|
|
+ private static final Long TEST_REPORT_ID = 999L;
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // Helper: build a fully mocked AIChatController
|
|
|
+ // ============================================================
|
|
|
+ private AIChatController buildChatController(
|
|
|
+ MembershipService membershipSvc,
|
|
|
+ AIService aiSvc,
|
|
|
+ FamilyContextService familyCtxSvc,
|
|
|
+ PortraitService portraitSvc,
|
|
|
+ SelfCheckAnalysisService selfCheckSvc,
|
|
|
+ TaskParseService taskParseSvc,
|
|
|
+ GrowthTaskService growthTaskSvc,
|
|
|
+ UserService userSvc,
|
|
|
+ ChatMirrorService chatMirrorSvc,
|
|
|
+ AiGateway aiGateway,
|
|
|
+ HealthAnalysisService healthAnalysisSvc,
|
|
|
+ RecommendationService recommendationSvc) throws Exception {
|
|
|
+
|
|
|
+ AIChatController c = new AIChatController();
|
|
|
+ inject(c, "membershipService", membershipSvc);
|
|
|
+ inject(c, "aiService", aiSvc);
|
|
|
+ inject(c, "familyContextService", familyCtxSvc);
|
|
|
+ inject(c, "portraitService", portraitSvc);
|
|
|
+ inject(c, "selfCheckAnalysisService", selfCheckSvc);
|
|
|
+ inject(c, "taskParseService", taskParseSvc);
|
|
|
+ inject(c, "growthTaskService", growthTaskSvc);
|
|
|
+ inject(c, "userService", userSvc);
|
|
|
+ inject(c, "chatMirrorService", chatMirrorSvc);
|
|
|
+ inject(c, "aiGateway", aiGateway);
|
|
|
+ inject(c, "healthAnalysisService", healthAnalysisSvc);
|
|
|
+ inject(c, "recommendationService", recommendationSvc);
|
|
|
+ return c;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 正常流:SC-B-01 成长咨询对话(chat/send 多轮)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01 正常:会员发送咨询消息 → 200 返回 answer + conversationId")
|
|
|
+ void scB01_正常_咨询对话_返回答案与会话ID() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ TaskParseService taskParseSvc = mock(TaskParseService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, aiSvc,
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), taskParseSvc,
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ // 门禁放行
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setEnabled(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ // LangGraph 返回回答
|
|
|
+ Map<String, Object> aiResp = new LinkedHashMap<>();
|
|
|
+ aiResp.put("answer", "孩子便秘建议多吃膳食纤维、适量运动、规律作息。");
|
|
|
+ aiResp.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ // 首轮无 conversationId(null)→ 第 3 参用 nullable 匹配 null 与非 null
|
|
|
+ when(aiSvc.sendMessage(anyString(), anyString(), nullable(String.class), anyMap()))
|
|
|
+ .thenReturn(aiResp);
|
|
|
+ // TaskParse 无 [TASK] 标记 → 原样返回清洗后文本
|
|
|
+ when(taskParseSvc.stripTaskMarkers(anyString()))
|
|
|
+ .thenAnswer(inv -> inv.getArgument(0));
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "孩子便秘吃什么好?");
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertNotNull(r.getData().get("answer"));
|
|
|
+ assertEquals(TEST_CONVERSATION_ID, r.getData().get("conversationId"));
|
|
|
+ verify(aiSvc).sendMessage(eq("孩子便秘吃什么好?"), eq("1"), eq(null), anyMap());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01 正常:多轮对话传入 conversationId → 透传给 AIService")
|
|
|
+ void scB01_正常_多轮对话_透传ConversationId() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, aiSvc,
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ Map<String, Object> aiResp = new LinkedHashMap<>();
|
|
|
+ aiResp.put("answer", "3 岁也可以,注意调整剂量。");
|
|
|
+ aiResp.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
|
|
|
+ .thenReturn(aiResp);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "那 3 岁可以吗?");
|
|
|
+ params.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ verify(aiSvc).sendMessage(eq("那 3 岁可以吗?"), eq("1"), eq(TEST_CONVERSATION_ID), anyMap());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01 正常:chat/send 解析 [TASK] 标记返回任务列表")
|
|
|
+ void scB01_正常_解析Task标记() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ TaskParseService taskParseSvc = mock(TaskParseService.class);
|
|
|
+ GrowthTaskService growthTaskSvc = mock(GrowthTaskService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, aiSvc,
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), taskParseSvc,
|
|
|
+ growthTaskSvc, mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ Map<String, Object> aiResp = new LinkedHashMap<>();
|
|
|
+ aiResp.put("answer", "建议每天喝水 [TASK: {\"title\":\"每日饮水\",\"dimension\":\"身\",\"rewardPoints\":5}] 8 杯");
|
|
|
+ aiResp.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
|
|
|
+ .thenReturn(aiResp);
|
|
|
+
|
|
|
+ // TaskParseService 返回解析结果
|
|
|
+ com.etotem.cfc.dto.TaskParseResult parsed = mock(com.etotem.cfc.dto.TaskParseResult.class);
|
|
|
+ when(parsed.getTitle()).thenReturn("每日饮水");
|
|
|
+ when(parsed.getDescription()).thenReturn("每天喝水 8 杯");
|
|
|
+ when(parsed.getDimension()).thenReturn("身");
|
|
|
+ when(parsed.getRewardPoints()).thenReturn(5);
|
|
|
+ when(taskParseSvc.parseTasks(anyString())).thenReturn(Collections.singletonList(parsed));
|
|
|
+ when(taskParseSvc.stripTaskMarkers(anyString())).thenReturn("建议每天喝水 8 杯");
|
|
|
+
|
|
|
+ com.etotem.cfc.entity.GrowthTask created = new com.etotem.cfc.entity.GrowthTask();
|
|
|
+ created.setId(888L);
|
|
|
+ created.setTitle("每日饮水");
|
|
|
+ created.setDescription("每天喝水 8 杯");
|
|
|
+ created.setDimension("身");
|
|
|
+ created.setRewardPoints(5);
|
|
|
+ when(growthTaskSvc.createDynamicTask(anyLong(), anyString(), anyString(), anyString(), anyInt(), anyString()))
|
|
|
+ .thenReturn(created);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "给个喝水任务");
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertTrue(r.getData().containsKey("tasks"));
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ List<Map<String, Object>> tasks = (List<Map<String, Object>>) r.getData().get("tasks");
|
|
|
+ assertEquals(1, tasks.size());
|
|
|
+ assertEquals("每日饮水", tasks.get(0).get("title"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 正常流:SC-B-11 报告细节追问(report/analyze + chat/send 追问上下文)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 正常:report/analyze 注入报告上下文 → 200 定向回答")
|
|
|
+ void scB11_正常_报告解读_注入上下文() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
|
|
|
+ UserService userSvc = mock(UserService.class);
|
|
|
+ TaskParseService taskParseSvc = mock(TaskParseService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, aiSvc,
|
|
|
+ familyCtxSvc, mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), taskParseSvc,
|
|
|
+ mock(GrowthTaskService.class), userSvc,
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ // 付费会员(非 FREE)→ 不走免费版单轮限制路径(付费版多轮,见 @Operation 摘要)
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ com.etotem.cfc.entity.User paidUser = new com.etotem.cfc.entity.User();
|
|
|
+ paidUser.setId(TEST_USER_ID);
|
|
|
+ paidUser.setMemberLevel("VIP");
|
|
|
+ when(userSvc.getUserInfo(TEST_USER_ID)).thenReturn(paidUser);
|
|
|
+ // TaskParse 无 [TASK] 标记 → 原样返回清洗后文本
|
|
|
+ when(taskParseSvc.stripTaskMarkers(anyString()))
|
|
|
+ .thenAnswer(inv -> inv.getArgument(0));
|
|
|
+
|
|
|
+ // buildChatInputs 注入报告上下文
|
|
|
+ Map<String, Object> reportContext = new LinkedHashMap<>();
|
|
|
+ reportContext.put("reportId", TEST_REPORT_ID);
|
|
|
+ reportContext.put("reportType", "growth");
|
|
|
+ reportContext.put("dimensionScores", mapOf("身", 70, "心", 65));
|
|
|
+ when(familyCtxSvc.buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID)))
|
|
|
+ .thenReturn(reportContext);
|
|
|
+
|
|
|
+ Map<String, Object> aiResp = new LinkedHashMap<>();
|
|
|
+ aiResp.put("answer", "报告显示身维 70 分,心维 65 分,建议重点加强心维活动。");
|
|
|
+ aiResp.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
|
|
|
+ .thenReturn(aiResp);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "报告里身维和心维什么意思?");
|
|
|
+ params.put("reportId", String.valueOf(TEST_REPORT_ID));
|
|
|
+ params.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ Result<Map<String, Object>> r = c.reportAnalyze(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertNotNull(r.getData().get("answer"));
|
|
|
+ verify(familyCtxSvc).buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 正常:chat/send 追问传入 reportId → 基于报告上下文回答")
|
|
|
+ void scB11_正常_追问传报告上下文() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
|
|
|
+ TaskParseService taskParseSvc = mock(TaskParseService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, aiSvc,
|
|
|
+ familyCtxSvc, mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), taskParseSvc,
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ // TaskParse 无 [TASK] 标记 → 原样返回清洗后文本
|
|
|
+ when(taskParseSvc.stripTaskMarkers(anyString()))
|
|
|
+ .thenAnswer(inv -> inv.getArgument(0));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ Map<String, Object> reportContext = new LinkedHashMap<>();
|
|
|
+ reportContext.put("reportId", TEST_REPORT_ID);
|
|
|
+ reportContext.put("riskItems", Collections.singletonList("菌种A偏低"));
|
|
|
+ when(familyCtxSvc.buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID)))
|
|
|
+ .thenReturn(reportContext);
|
|
|
+
|
|
|
+ Map<String, Object> aiResp = new LinkedHashMap<>();
|
|
|
+ aiResp.put("answer", "菌种A偏低可能影响肠道屏障,建议补充益生元。");
|
|
|
+ aiResp.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ when(aiSvc.sendMessage(anyString(), anyString(), anyString(), anyMap()))
|
|
|
+ .thenReturn(aiResp);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "菌种A偏低有什么影响?");
|
|
|
+ params.put("reportId", String.valueOf(TEST_REPORT_ID));
|
|
|
+ params.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertNotNull(r.getData().get("answer"));
|
|
|
+ verify(familyCtxSvc).buildContext(eq(TEST_USER_ID), eq(TEST_REPORT_ID));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 正常:chat/conversations 返回会话列表(回退本地镜像)")
|
|
|
+ void scB11_正常_会话列表_回退本地() throws Exception {
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ ChatMirrorService chatMirrorSvc = mock(ChatMirrorService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ mock(MembershipService.class), aiSvc,
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ chatMirrorSvc, mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ // LangGraph 抛异常 → 回退本地镜像
|
|
|
+ when(aiSvc.getConversations(anyString())).thenThrow(new RuntimeException("LangGraph 不可用"));
|
|
|
+
|
|
|
+ com.etotem.cfc.entity.ChatConversation localConv = new com.etotem.cfc.entity.ChatConversation();
|
|
|
+ localConv.setId(100L);
|
|
|
+ localConv.setDifyConversationId(TEST_CONVERSATION_ID);
|
|
|
+ localConv.setTitle("成长咨询");
|
|
|
+ localConv.setLastMessageAt(new Date());
|
|
|
+ when(chatMirrorSvc.getConversations(TEST_USER_ID)).thenReturn(Collections.singletonList(localConv));
|
|
|
+
|
|
|
+ Result<List<Map<String, Object>>> r = c.getConversations(TEST_USER_ID);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals(1, r.getData().size());
|
|
|
+ assertEquals(TEST_CONVERSATION_ID, r.getData().get(0).get("id"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 正常:chat/messages 返回指定会话消息历史")
|
|
|
+ void scB11_正常_消息历史() throws Exception {
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ ChatMirrorService chatMirrorSvc = mock(ChatMirrorService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ mock(MembershipService.class), aiSvc,
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ chatMirrorSvc, mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ when(aiSvc.getMessages(anyString(), anyString())).thenThrow(new RuntimeException("LangGraph 不可用"));
|
|
|
+
|
|
|
+ com.etotem.cfc.entity.ChatConversation localConv = new com.etotem.cfc.entity.ChatConversation();
|
|
|
+ localConv.setId(200L);
|
|
|
+ localConv.setDifyConversationId(TEST_CONVERSATION_ID);
|
|
|
+ when(chatMirrorSvc.getConversations(TEST_USER_ID)).thenReturn(Collections.singletonList(localConv));
|
|
|
+
|
|
|
+ com.etotem.cfc.entity.ChatMessage msg1 = new com.etotem.cfc.entity.ChatMessage();
|
|
|
+ msg1.setRole("user");
|
|
|
+ msg1.setContent("你好");
|
|
|
+ msg1.setCreatedAt(new Date());
|
|
|
+ com.etotem.cfc.entity.ChatMessage msg2 = new com.etotem.cfc.entity.ChatMessage();
|
|
|
+ msg2.setRole("assistant");
|
|
|
+ msg2.setContent("您好,有什么可以帮您?");
|
|
|
+ msg2.setCreatedAt(new Date());
|
|
|
+ when(chatMirrorSvc.getMessages(200L)).thenReturn(Arrays.asList(msg1, msg2));
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("conversationId", TEST_CONVERSATION_ID);
|
|
|
+ Result<List<Map<String, Object>>> r = c.getMessages(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals(2, r.getData().size());
|
|
|
+ assertEquals("user", r.getData().get(0).get("role"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 正常:删除会话 → 200")
|
|
|
+ void scB11_正常_删除会话() throws Exception {
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ mock(MembershipService.class), aiSvc,
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ doNothing().when(aiSvc).deleteConversation(eq(TEST_CONVERSATION_ID), anyString());
|
|
|
+
|
|
|
+ Result<Void> r = c.deleteConversation(TEST_USER_ID, TEST_CONVERSATION_ID);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ verify(aiSvc).deleteConversation(eq(TEST_CONVERSATION_ID), eq("1"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 正常流:AIBehaviorController /api/ai/behavior/summary
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01/B-11 正常:/api/ai/behavior/summary 返回行为摘要")
|
|
|
+ void scB01_B11_正常_行为摘要() throws Exception {
|
|
|
+ FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
|
|
|
+ AIBehaviorController c = new AIBehaviorController();
|
|
|
+ inject(c, "familyContextService", familyCtxSvc);
|
|
|
+
|
|
|
+ // 契约(设计面):/api/ai/behavior/summary 为「行为摘要」端点(TDD 设计溯源列为
|
|
|
+ // ai.coach.consult 载体之一)。当前实现 = 透传 familyContextService.buildContext(userId)
|
|
|
+ // 通用家庭上下文;专用行为摘要聚合(五维能量趋势/任务完成率/异常指标)属 GREEN 阶段
|
|
|
+ // 增强项(若契约要求),本用例仅钉住现有接线:200 + member_id 注入 context。
|
|
|
+ Map<String, Object> genericContext = new LinkedHashMap<>();
|
|
|
+ genericContext.put("familyId", 7L);
|
|
|
+ genericContext.put("memberId", null);
|
|
|
+ when(familyCtxSvc.buildContext(TEST_USER_ID)).thenReturn(genericContext);
|
|
|
+
|
|
|
+ Map<String, Object> params = new HashMap<>();
|
|
|
+ params.put("member_id", TEST_MEMBER_ID);
|
|
|
+ Result<Map<String, Object>> r = c.getBehaviorSummary(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ // 现有接线断言:member_id 覆盖注入(行为摘要按指定成员返回)
|
|
|
+ assertEquals(TEST_MEMBER_ID, r.getData().get("memberId"),
|
|
|
+ "传入 member_id 时应注入到行为摘要上下文中");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 异常流
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01 异常:空消息 → 400「内容不能为空」(契约要求 400,非 500)")
|
|
|
+ void scB01_异常_空消息_返回400() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, mock(AIService.class),
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ // 门禁放行(空消息校验在门禁前)
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, new HashMap<>());
|
|
|
+
|
|
|
+ // 契约要求:空消息返回 400(当前实现返回 500 "消息不能为空")
|
|
|
+ assertEquals(400, r.getCode(), "空消息应返回 400 Bad Request,而非 500");
|
|
|
+ assertTrue(r.getMessage().contains("内容不能为空") || r.getMessage().contains("消息不能为空"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01 异常:超长文本(>8000 字) → 400 长度上限")
|
|
|
+ void scB01_异常_超长文本_返回400() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, mock(AIService.class),
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ String longQuery = repeat("x", 8001);
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", longQuery);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ // 契约要求:超长文本返回 400 明确提示长度上限(当前实现无长度校验)
|
|
|
+ assertEquals(400, r.getCode(), "超长文本(>8000 字)应返回 400 长度上限");
|
|
|
+ assertTrue(r.getMessage().contains("长度") || r.getMessage().contains("超长") || r.getMessage().contains("上限"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 异常:chat/messages 缺少 conversationId → 400")
|
|
|
+ void scB11_异常_缺少ConversationId_返回400() throws Exception {
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ mock(MembershipService.class), mock(AIService.class),
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ Result<List<Map<String, Object>>> r = c.getMessages(TEST_USER_ID, new HashMap<>());
|
|
|
+
|
|
|
+ // 当前实现返回 500 "conversationId 不能为空",契约要求 400
|
|
|
+ assertEquals(400, r.getCode(), "缺少 conversationId 应返回 400");
|
|
|
+ assertTrue(r.getMessage().contains("conversationId"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 异常:report/analyze 缺少 reportId/draftId → 400")
|
|
|
+ void scB11_异常_缺少报告Id_返回400() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, mock(AIService.class),
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "解读一下");
|
|
|
+ Result<Map<String, Object>> r = c.reportAnalyze(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ // 当前实现返回 500 "报告ID或草稿ID不能为空",契约要求 400
|
|
|
+ assertEquals(400, r.getCode(), "缺少报告 ID 应返回 400");
|
|
|
+ assertTrue(r.getMessage().contains("报告"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // 权限流
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01 权限:非会员(FREE)调用 chat/send → 46003 会员专享")
|
|
|
+ void scB01_权限_非会员_返回46003() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, mock(AIService.class),
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(false);
|
|
|
+ gate.setEnabled(true);
|
|
|
+ gate.setStatus(AiGateStatus.UPGRADE_REQUIRED);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "测试");
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(46003, r.getCode());
|
|
|
+ assertTrue(r.getMessage().contains("会员"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-01 权限:会员已过期 → 46004 续费引导")
|
|
|
+ void scB01_权限_会员过期_返回46004() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, mock(AIService.class),
|
|
|
+ mock(FamilyContextService.class), mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), mock(TaskParseService.class),
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(false);
|
|
|
+ gate.setStatus(AiGateStatus.MEMBER_EXPIRED);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "测试");
|
|
|
+ Result<Map<String, Object>> r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+
|
|
|
+ assertEquals(46004, r.getCode());
|
|
|
+ assertTrue(r.getMessage().contains("过期") || r.getMessage().contains("续费"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("SC-B-11 权限:chat/send 越权查询他人报告 → 拒绝且不泄漏到 LangGraph")
|
|
|
+ void scB11_权限_越权报告_拒绝不泄漏() throws Exception {
|
|
|
+ MembershipService membershipSvc = mock(MembershipService.class);
|
|
|
+ AIService aiSvc = mock(AIService.class);
|
|
|
+ FamilyContextService familyCtxSvc = mock(FamilyContextService.class);
|
|
|
+ TaskParseService taskParseSvc = mock(TaskParseService.class);
|
|
|
+ AIChatController c = buildChatController(
|
|
|
+ membershipSvc, aiSvc,
|
|
|
+ familyCtxSvc, mock(PortraitService.class),
|
|
|
+ mock(SelfCheckAnalysisService.class), taskParseSvc,
|
|
|
+ mock(GrowthTaskService.class), mock(UserService.class),
|
|
|
+ mock(ChatMirrorService.class), mock(AiGateway.class),
|
|
|
+ mock(HealthAnalysisService.class), mock(RecommendationService.class));
|
|
|
+
|
|
|
+ AiGateResult gate = new AiGateResult();
|
|
|
+ gate.setCanUse(true);
|
|
|
+ gate.setStatus(AiGateStatus.ALLOW);
|
|
|
+ when(membershipSvc.checkAiFeatureGate(TEST_USER_ID)).thenReturn(gate);
|
|
|
+ when(taskParseSvc.stripTaskMarkers(anyString()))
|
|
|
+ .thenAnswer(inv -> inv.getArgument(0));
|
|
|
+
|
|
|
+ // 越权:当前用户查询其他家庭的报告(8888),上下文服务对越权报告只应返回
|
|
|
+ // 「无法构建/拒绝」语义(null)——契约要求控制器在此边界拒答,不组装他人
|
|
|
+ // 报告上下文、不调用 LangGraph、不返回 200+泄露内容。
|
|
|
+ Long otherReportId = 8888L;
|
|
|
+ when(familyCtxSvc.buildContext(eq(TEST_USER_ID), eq(otherReportId)))
|
|
|
+ .thenReturn(null);
|
|
|
+
|
|
|
+ Map<String, String> params = new HashMap<>();
|
|
|
+ params.put("query", "看看这个报告");
|
|
|
+ params.put("reportId", String.valueOf(otherReportId));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r;
|
|
|
+ try {
|
|
|
+ r = c.sendMessage(TEST_USER_ID, params);
|
|
|
+ } catch (Exception e) {
|
|
|
+ fail("[ai.coach.consult] 越权报告上下文缺失时控制器异常(" + e.getClass().getSimpleName()
|
|
|
+ + ": " + e.getMessage() + "),契约要求优雅拒答(403/错误码),不 NPE 不 500");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 契约要求:越权报告不得返回 200 + 泄露回答;GREEN 侧应返回 403 或业务错误码
|
|
|
+ assertNotEquals(200, r.getCode(),
|
|
|
+ "越权查询他人报告应被拒绝(返回 403/业务错误码),不得返回 200 泄露数据");
|
|
|
+ verify(aiSvc, never()).sendMessage(anyString(), anyString(), anyString(), anyMap());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // Reflection inject helper (from AssessmentPlanContractTestBase)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ private static void inject(Object target, String fieldName, Object value) throws Exception {
|
|
|
+ Class<?> cls = target.getClass();
|
|
|
+ Field f = null;
|
|
|
+ while (cls != null && f == null) {
|
|
|
+ try {
|
|
|
+ f = cls.getDeclaredField(fieldName);
|
|
|
+ } catch (NoSuchFieldException e) {
|
|
|
+ cls = cls.getSuperclass();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (f == null) {
|
|
|
+ throw new NoSuchFieldException(target.getClass().getName() + "#" + fieldName);
|
|
|
+ }
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Java 8 兼容:构造小 Map(避免 List.of/Map.of 的 Java 9+ API) */
|
|
|
+ private static Map<String, Object> mapOf(Object... kv) {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ for (int i = 0; i + 1 < kv.length; i += 2) {
|
|
|
+ m.put(String.valueOf(kv[i]), kv[i + 1]);
|
|
|
+ }
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Java 8 兼容:拼接 n 次字符串(String.repeat 为 Java 11+ API) */
|
|
|
+ private static String repeat(String s, int n) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (int i = 0; i < n; i++) {
|
|
|
+ sb.append(s);
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+}
|