|
@@ -0,0 +1,230 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.service.AiGateway;
|
|
|
|
|
+import com.etotem.cfc.service.PiisService;
|
|
|
|
|
+import com.sun.net.httpserver.HttpServer;
|
|
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoSettings;
|
|
|
|
|
+import org.mockito.quality.Strictness;
|
|
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.IOException;
|
|
|
|
|
+import java.io.OutputStream;
|
|
|
|
|
+import java.net.InetSocketAddress;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+import java.util.concurrent.atomic.AtomicInteger;
|
|
|
|
|
+import java.util.concurrent.atomic.AtomicReference;
|
|
|
|
|
+
|
|
|
|
|
+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.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:LangGraph 上游 429/401 必须传播为外部服务错误,不得静默吞成 null(能力键
|
|
|
|
|
+ * {@code langgraph.intent.errorPropagate})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>需求/设计锚点</b>(B-134-4 P0 第 2 轮 + B-74-Update 延续;cfc-langgraph 侧根因):
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>{@code cfc-langgraph app/agents/intent_classifier.py:62}:捕获 429 后<b>默认回落 chat</b>,
|
|
|
|
|
+ * 把<b>配额错误伪装成分类错误</b>——契约要求 429/401 <b>抛外部服务异常</b>
|
|
|
|
|
+ * (供编排降级/告警),不得兜底默认 chat;</li>
|
|
|
|
|
+ * <li>{@code cfc-langgraph app/graphs/chat_graph.py:148}:记忆召回凭据校验——401
|
|
|
|
|
+ * (credential invalid)同样必须向外暴露,不得降级伪装。</li>
|
|
|
|
|
+ * </ul>
|
|
|
|
|
+ * <b>Java 侧镜像契约</b>:{@code AiGateway} 是 Java 侧 LangGraph 统一网关(AGENTS.md:
|
|
|
|
|
+ * 「Java 侧通过 AiGateway 调用(熔断 + 超时 + Fallback),禁止绕过」)。上游以
|
|
|
|
|
+ * HTTP 429/401 拒绝时,网关必须<b>向上传播外部服务错误</b>(抛异常,或返回携带外部服务
|
|
|
|
|
+ * 错误标记的结果),<b>不得静默返回 null</b>——null 在调用方被当作「普通失败/可兜底」,
|
|
|
|
|
+ * 正是 B-134-4 所述「配额错误伪装成分类错误」的 Java 侧等价形态。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状(RED 依据)</b>:宿主类 {@code AiGateway.chat()/chatForAnalysis()} 现有
|
|
|
|
|
+ * {@code catch (Exception e) { log.warn(...); recordFailure(); return null; }}——
|
|
|
|
|
+ * 429/401 上调({@code HttpClientErrorException})与其他失败一样被吞掉兜底为 null,
|
|
|
|
|
+ * 未区分、未传播。因此「429/401 必须传播(抛异常或返回错误标记),不得静默返 null」
|
|
|
|
|
+ * 断言当前失败(红)。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>测试范围</b>:契约级单测——启动本进程内 {@code HttpServer}(与已合入的
|
|
|
|
|
+ * {@code AiGatewayTimeoutTest} 同款范式)模拟 LangGraph 上游返回 429/401/200,mock
|
|
|
|
|
+ * {@code PiisService};无 Spring 容器 / 无 DB / 无真实外部服务;不触碰 src/main。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>权限流</b>:本能力为 Java 侧 LangGraph 网关的错误传播契约(userId=0 系统级调用,
|
|
|
|
|
+ * 不走用户配额),无用户权限维度,权限流不适用。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>RED 预期</b>:正常流 1/2/3(chat 429 / chat 401 / chatForAnalysis 429 必须传播)
|
|
|
|
|
+ * 失败(红);正常流 4(200 成功路径正常返回 answer)为契约不变式/锚点,当前通过(绿)。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+@MockitoSettings(strictness = Strictness.LENIENT)
|
|
|
|
|
+class LangGraphErrorPropagateContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** 本进程内模拟 LangGraph 上游的服务节点 */
|
|
|
|
|
+ private HttpServer server;
|
|
|
|
|
+
|
|
|
|
|
+ /** 上游响应:状态码 + 响应体(由测试方法按需切换,模拟 429/401/200) */
|
|
|
|
|
+ private final AtomicInteger upstreamStatus = new AtomicInteger(200);
|
|
|
|
|
+ private final AtomicReference<String> upstreamBody = new AtomicReference<>("{}");
|
|
|
|
|
+
|
|
|
|
|
+ private AiGateway gateway;
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private PiisService piisService;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() throws IOException {
|
|
|
|
|
+ // 模拟 LangGraph 上游:接收任意 POST,按 upstreamStatus/upstreamBody 回复
|
|
|
|
|
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
|
|
|
|
+ server.setExecutor(runnable -> {
|
|
|
|
|
+ Thread t = new Thread(runnable);
|
|
|
|
|
+ t.setDaemon(true);
|
|
|
|
|
+ t.start();
|
|
|
|
|
+ });
|
|
|
|
|
+ server.createContext("/", exchange -> {
|
|
|
|
|
+ byte[] body = upstreamBody.get().getBytes(StandardCharsets.UTF_8);
|
|
|
|
|
+ // 必须显式声明 UTF-8:RestTemplate StringHttpMessageConverter 默认 ISO-8859-1 会破坏中文 body
|
|
|
|
|
+ exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8");
|
|
|
|
|
+ exchange.sendResponseHeaders(upstreamStatus.get(), body.length);
|
|
|
|
|
+ try (OutputStream os = exchange.getResponseBody()) {
|
|
|
|
|
+ os.write(body);
|
|
|
|
|
+ }
|
|
|
|
|
+ exchange.close();
|
|
|
|
|
+ });
|
|
|
|
|
+ server.start();
|
|
|
|
|
+
|
|
|
|
|
+ gateway = new AiGateway();
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "enabled", true);
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "baseUrl", "http://127.0.0.1:" + server.getAddress().getPort());
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "timeoutMs", 3000);
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "readTimeoutMs", 5000);
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "failureThreshold", 3);
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "resetTimeoutMs", 30000);
|
|
|
|
|
+
|
|
|
|
|
+ // PII 脱敏直通
|
|
|
|
|
+ when(piisService.sanitizeText(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
|
|
|
+ when(piisService.sanitizeContext(any(), any())).thenAnswer(inv -> inv.getArgument(0));
|
|
|
|
|
+ ReflectionTestUtils.setField(gateway, "piisService", piisService);
|
|
|
|
|
+
|
|
|
|
|
+ gateway.init();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @AfterEach
|
|
|
|
|
+ void tearDown() {
|
|
|
|
|
+ if (server != null) server.stop(0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 正常流:上游 429/401 必须传播为外部服务错误(不得静默返 null)
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流 1:chat() 遇上游 429(配额超限)不得静默返 null,须传播外部服务错误")
|
|
|
|
|
+ void chat_upstream429_mustPropagate() {
|
|
|
|
|
+ upstreamStatus.set(429);
|
|
|
|
|
+
|
|
|
|
|
+ Capture c = capture(() -> gateway.chat("你好", 0L, "c1", new HashMap<>()));
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(c.thrown != null || c.result != null,
|
|
|
|
|
+ "[langgraph.intent.errorPropagate] AiGateway.chat() 遇上游 429 必须传播外部服务错误"
|
|
|
|
|
+ + "(抛异常或返回错误标记,供编排降级/告警),不得静默返回 null——"
|
|
|
|
|
+ + "静默 null 即「配额错误伪装成普通失败」(B-134-4 同源);"
|
|
|
|
|
+ + "当前 swallowed=" + (c.thrown == null ? "null" : c.thrown.toString())
|
|
|
|
|
+ + " result=" + c.result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流 2:chat() 遇上游 401(记忆召回凭据无效)不得静默返 null,须传播")
|
|
|
|
|
+ void chat_upstream401_mustPropagate() {
|
|
|
|
|
+ upstreamStatus.set(401);
|
|
|
|
|
+
|
|
|
|
|
+ Capture c = capture(() -> gateway.chat("你好", 0L, "c1", new HashMap<>()));
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(c.thrown != null || c.result != null,
|
|
|
|
|
+ "[langgraph.intent.errorPropagate] AiGateway.chat() 遇上游 401(记忆召回凭据无效)"
|
|
|
|
|
+ + "必须传播外部服务错误,不得静默返回 null(chat_graph.py:148 同源);"
|
|
|
|
|
+ + "当前 swallowed=" + (c.thrown == null ? "null" : c.thrown.toString())
|
|
|
|
|
+ + " result=" + c.result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流 3:chatForAnalysis() 遇上游 429 不得静默返 null,须传播")
|
|
|
|
|
+ void chatForAnalysis_upstream429_mustPropagate() {
|
|
|
|
|
+ upstreamStatus.set(429);
|
|
|
|
|
+
|
|
|
|
|
+ Capture c = capture(() -> gateway.chatForAnalysis("分析", 0L, "c1", new HashMap<>()));
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(c.thrown != null || c.result != null,
|
|
|
|
|
+ "[langgraph.intent.errorPropagate] AiGateway.chatForAnalysis() 遇上游 429 必须传播"
|
|
|
|
|
+ + "外部服务错误,不得静默返回 null(分析型对话与 chat() 传播语义一致);"
|
|
|
|
|
+ + "当前 swallowed=" + (c.thrown == null ? "null" : c.thrown.toString())
|
|
|
|
|
+ + " result=" + c.result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 正常流:成功路径锚点(契约不变式——错误传播改造不得破坏正常调用)
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流 4:上游 200 成功时 chat() 正常返回 answer(GREEN 锚点/契约不变式)")
|
|
|
|
|
+ void chat_upstreamSuccess_returnsAnswer() {
|
|
|
|
|
+ upstreamStatus.set(200);
|
|
|
|
|
+ upstreamBody.set("{\"answer\":\"你好,有什么可以帮你?\",\"conversation_id\":\"c1\",\"tasks\":[]}");
|
|
|
|
|
+
|
|
|
|
|
+ Capture c = capture(() -> gateway.chat("你好", 0L, "c1", new HashMap<>()));
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(c.thrown == null, "[langgraph.intent.errorPropagate] 成功路径不应抛异常: " + c.thrown);
|
|
|
|
|
+ assertNotNull(c.result, "[langgraph.intent.errorPropagate] 成功路径不应返回 null");
|
|
|
|
|
+ assertTrue(c.result instanceof Map && "你好,有什么可以帮你?"
|
|
|
|
|
+ .equals(((Map<?, ?>) c.result).get("answer")),
|
|
|
|
|
+ "[langgraph.intent.errorPropagate] 成功路径应正常返回 answer 字段(锚点)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 权限流(不适用):语言网关错误传播契约,无用户权限维度
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流:本能力为 Java 侧 LangGraph 网关错误传播契约,无用户权限维度,权限流不适用")
|
|
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
|
|
+ // 说明性用例:errorPropagate 为网关层错误语义(429/401 传播),不涉及用户权限校验;
|
|
|
|
|
+ // 仅为三流覆盖标注「不适用」,不产生断言失败。
|
|
|
|
|
+ assertTrue(true, "权限流不适用:langgraph.intent.errorPropagate 为网关错误传播契约,无用户权限维度");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 辅助
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ private Capture capture(Call call) {
|
|
|
|
|
+ Object result = null;
|
|
|
|
|
+ Throwable thrown = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ result = call.invoke();
|
|
|
|
|
+ } catch (Throwable t) {
|
|
|
|
|
+ thrown = t;
|
|
|
|
|
+ }
|
|
|
|
|
+ return new Capture(result, thrown);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private interface Call {
|
|
|
|
|
+ Object invoke() throws Exception;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static final class Capture {
|
|
|
|
|
+ final Object result;
|
|
|
|
|
+ final Throwable thrown;
|
|
|
|
|
+
|
|
|
|
|
+ Capture(Object result, Throwable thrown) {
|
|
|
|
|
+ this.result = result;
|
|
|
|
|
+ this.thrown = thrown;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|