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

test(langgraph): S-cfc-136 契约测试 langgraph.intent.errorPropagate - 上游429/401必须传播不得吞null(RED 3失败)

Sisyphus Agent 2 дней назад
Родитель
Сommit
8f5fe923e5

+ 230 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/LangGraphErrorPropagateContractTest.java

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