"""`W29-d` · NL2SQL 接线后的 **HTTP 链路**验证(此前只测到服务层)。 ## 为什么需要它 `W29` 把 `agent_tools/financial_nl2sql:financial_query` 发布出去之后,验证只做到两层: - **服务层直调**:`FinancialNL2SQLService().query(...)`; - **工具层**:自建 `ToolExecutor` + **手写** `configured_tools={"financial_query": ("query_financial_data",)}`。 这两层都**绕过了**真实链路最关键的一段:`agent_tools` 白名单是**从库里装配**的 (`AgentFactory` 读 `config_release` 的生效快照)。手写 `configured_tools` 等于 **把要验证的东西自己填好了** —— 它能证明"工具逻辑对",但**证明不了"接线生效"**。 库里的证据也印证了这一点:`agent_run` 表里 `agent_type='financial_nl2sql'` **0 条**。 本文件走完整链路:HTTP 受理 → Worker 从**生效配置**装配白名单 → 工具执行 → 落库 → 读出参。 """ from __future__ import annotations from uuid import uuid4 import httpx import pytest from sqlalchemy import delete from app.api.dependencies.auth import build_request_context from app.core.contracts import RequestContext from app.infrastructure.db import SessionFactory from app.main import create_app from app.model.conversation import ConversationMessage from app.model.platform import AgentRun from app.service.agent.bootstrap import get_agent_factory from app.worker.runtime import WorkerRuntime WRITE_WORDS = ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE", "GRANT", "REVOKE") @pytest.mark.integration async def test_financial_nl2sql_serves_over_http_after_wiring() -> None: """接线后,`financial_nl2sql` 必须能在**真实 HTTP 链路**上出结果。 断言的是"端到端可用",不是"工具逻辑正确"(后者由 `test_financial_nl2sql_*.py` 覆盖)。 两者会分别失败,所以分开测 —— 否则"NL2SQL 坏了"这句话定位不到是哪一层。 """ context = RequestContext( user_id="1", trace_id=str(uuid4()), roles=("operator",), data_scope="all", portal="api", permissions=("agent:run", "financial:nl2sql:read"), ) app = create_app() app.dependency_overrides[build_request_context] = lambda: context session_id = f"w29d-nl2sql-{uuid4()}" run_id: str | None = None async def identity(value: object) -> RequestContext: # 必须是协程函数:`restore_context` 会 `await` 它。 del value return context runtime = WorkerRuntime(get_agent_factory(), resolve_identity=identity) try: async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: accepted = await client.post("/api/v1/agent-runs", json={ "agent_type": "financial_nl2sql", "message": "查询近30天净值", "session_id": session_id, "idempotency_key": str(uuid4()), }) assert accepted.status_code == 202, accepted.text run_id = accepted.json()["data"]["run_id"] assert await runtime.dispatch_one(run_id=run_id), "run 未派发成功" await runtime.execute(run_id) snapshot = (await client.get(f"/api/v1/agent-runs/{run_id}")).json()["data"] assert snapshot["status"] == "succeeded", snapshot result = snapshot["result"] or {} # ① 出参:`RunQueryService` **只**对 `financial_nl2sql` 暴露 `data` 与 `sql` assert isinstance(result.get("data"), dict), f"出参缺 data:{sorted(result)}" assert isinstance(result.get("sql"), str), f"出参缺 sql:{sorted(result)}" # ② 只读自证(工具声明 `read_only=True`,这里验它真的没产出写语句) sql = result["sql"] upper = sql.upper() assert upper.startswith("SELECT"), f"只读工具产出了非 SELECT:{sql}" assert not any(word in upper for word in WRITE_WORDS), sql assert "*" not in sql, f"禁止 SELECT *:{sql}" assert "LIMIT" in upper, f"缺 LIMIT:{sql}" # ③ 真的**执行**了,而不是只编译完就返回 —— `data.total` 只有执行后才填 assert "total" in result["data"], f"查询未真正执行:{result['data']}" finally: async with SessionFactory() as session, session.begin(): await session.execute( delete(ConversationMessage).where(ConversationMessage.session_id == session_id) ) if run_id: await session.execute(delete(AgentRun).where(AgentRun.run_id == run_id))