deer-flow/backend/tests/test_business_plugins.py
Wenchao An 42334f26d7
feat(capabilities): unify catalog, plugin configuration and agent selection (#5497)
* feat(capabilities): unify catalog, plugin configuration and agent selection

* fix(capabilities): address review isolation, validation and demo issues

* fix(capabilities): preserve concurrent selections and guide launcher repair
2026-09-19 12:14:24 +08:00

169 lines
7.6 KiB
Python

"""Provider contracts: actual HTTP serialization, business errors and MCP tools."""
import json
import httpx
import pytest
from deerflow.capabilities.business import BusinessClient, build_server, connection_config
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider,credentials,host",
[
("dingtalk", {"access_token": "robot-token", "sign_secret": "SEC-sign"}, "oapi.dingtalk.com"),
("wecom", {"webhook_key": "robot-key"}, "qyapi.weixin.qq.com"),
],
)
async def test_robot_wire_contract(provider, credentials, host):
requests = []
def handle(request):
requests.append(request)
return httpx.Response(200, json={"errcode": 0, "errmsg": "ok"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
client = BusinessClient(provider, credentials, http)
assert await client.send_message("通知", "markdown", "日报") == {"sent": True}
request = requests[0]
assert request.url.host == host
body = json.loads(request.content)
assert body["msgtype"] == "markdown"
if provider == "dingtalk":
assert request.url.params["access_token"] == "robot-token"
assert request.url.params["timestamp"] and request.url.params["sign"]
assert body["markdown"] == {"text": "通知", "title": "日报"}
else:
assert request.url.params["key"] == "robot-key"
assert body["markdown"] == {"content": "通知"}
@pytest.mark.asyncio
@pytest.mark.parametrize("status,body", [(200, {"errcode": 40014, "errmsg": "secret-token"}), (401, {"message": "secret-token"}), (302, {})])
async def test_errors_never_claim_success_or_expose_response_secrets(status, body):
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(status, json=body))) as http:
client = BusinessClient("wecom", {"webhook_key": "secret-token"}, http)
with pytest.raises(ValueError) as error:
await client.send_message("hello")
assert "secret-token" not in str(error.value)
@pytest.mark.asyncio
async def test_hubspot_pagination_and_contact_creation():
requests = []
def handle(request):
requests.append(request)
if request.method == "GET":
return httpx.Response(200, json={"results": [{"id": "12"}], "paging": {"next": {"after": "next-page"}}})
return httpx.Response(201, json={"id": "42", "properties": {"email": "person@example.test"}})
async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
client = BusinessClient("hubspot", {"access_token": "private-token"}, http)
assert (await client.get_companies(2, "cursor"))["next_after"] == "next-page"
assert (await client.create_contact("person@example.test", firstname="A"))["id"] == "42"
assert requests[0].url.params["after"] == "cursor"
assert requests[0].url.params["limit"] == "2"
assert requests[0].headers["Authorization"] == "Bearer private-token"
assert requests[1].url.path == "/crm/v3/objects/contacts"
assert json.loads(requests[1].content) == {"properties": {"email": "person@example.test", "firstname": "A"}}
@pytest.mark.asyncio
async def test_mcp_discovery_does_not_need_or_expose_credentials():
server = build_server("hubspot")
tools = await server.list_tools()
assert {tool.name for tool in tools} == {"get_companies", "create_contact"}
for tool in tools:
assert "access_token" not in json.dumps(tool.inputSchema)
assert tool.annotations.readOnlyHint == (tool.name == "get_companies")
@pytest.mark.parametrize("provider,values", [("wecom", {}), ("dingtalk", {"access_token": "x"}), ("hubspot", {"access_token": "x", "url": "http://localhost"}), ("hubspot", {"access_token": "***"})])
def test_invalid_configuration_rejected(provider, values):
with pytest.raises(ValueError):
connection_config(provider, values)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider,credentials,tool,arguments",
[
("dingtalk", {"access_token": "fixture-token", "sign_secret": "fixture-secret"}, "send_message", {"content": ""}),
("wecom", {"webhook_key": "fixture-key"}, "send_message", {"content": ""}),
("hubspot", {"access_token": "fixture-token"}, "create_contact", {"email": "invalid"}),
],
)
async def test_exact_installed_launcher_discovers_and_rejects_invalid_calls(provider, credentials, tool, arguments):
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
config = connection_config(provider, credentials)
params = StdioServerParameters(command=config["command"], args=config["args"], env=config["env"])
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
await session.initialize()
discovered = await session.list_tools()
assert tool in {item.name for item in discovered.tools}
result = await session.call_tool(tool, arguments)
assert result.isError
for secret in credentials.values():
assert secret not in result.model_dump_json()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"provider,credentials,tool,arguments,expected",
[
("dingtalk", {"access_token": "fixture-token", "sign_secret": "fixture-secret"}, "send_message", {"content": "Report ready"}, "sent"),
("wecom", {"webhook_key": "fixture-key"}, "send_message", {"content": "Report ready"}, "sent"),
("hubspot", {"access_token": "fixture-token"}, "get_companies", {"limit": 1}, "Acme"),
("hubspot", {"access_token": "fixture-token"}, "create_contact", {"email": "person@example.test"}, "contact-42"),
],
)
async def test_real_mcp_tool_invocation_with_simulated_provider(tmp_path, provider, credentials, tool, arguments, expected):
"""Real stdio protocol and client code; only external HTTP is simulated."""
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
script = tmp_path / "business_fixture.py"
script.write_text(
"""import httpx
from deerflow.capabilities.business import build_server
import sys
original = httpx.AsyncClient
def handle(request):
if request.url.host == "api.hubapi.com":
if request.method == "GET":
return httpx.Response(200, json={"results": [{"id": "1", "properties": {"name": "Acme"}}]})
return httpx.Response(201, json={"id": "contact-42", "properties": {"email": "person@example.test"}})
return httpx.Response(200, json={"errcode": 0})
httpx.AsyncClient = lambda: original(transport=httpx.MockTransport(handle))
build_server(sys.argv[1]).run()
""",
encoding="utf-8",
)
params = StdioServerParameters(command=sys.executable, args=["-I", str(script), provider], env=connection_config(provider, credentials)["env"])
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(tool, arguments)
assert not result.isError, result
assert expected in result.model_dump_json()
@pytest.mark.asyncio
async def test_robot_missing_business_status_is_failure_and_network_exception_is_redacted():
async def missing(_):
return httpx.Response(200, json={"ok": True})
def broken(request):
raise httpx.ConnectError("https://example.test?key=super-secret", request=request)
for handler in (missing, broken):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
with pytest.raises(ValueError) as error:
await BusinessClient("wecom", {"webhook_key": "super-secret"}, http).send_message("hello")
assert "super-secret" not in str(error.value)