deer-flow/backend/tests/test_jina_client.py
kongxin cee54cdd49
fix(web-fetch): stop sending .env.example placeholder keys to providers (#8206)
* fix(web-fetch): stop sending .env.example placeholder keys to providers

make config and make setup copy .env.example into .env, and the template
set nine search/fetch provider keys to example values such as
JINA_API_KEY=your-jina-api-key. The default keyless Jina Reader sent
that text as a Bearer key, so every web_fetch on a fresh install failed
with 401 while make doctor reported web fetch as configured.

- .env.example comments those nine lines out, like every other credential
  in the template already is.
- provider_credentials.resolve_api_key reads a your-...-key/-token/-secret
  value as unset, so existing .env files that still hold the placeholders
  recover without editing, for every provider that resolves keys there.

* chore: leave the changelog entry to merge time

Per CONTRIBUTING.md (#8202), maintainers add changelog entries when they
merge a pull request.

* fix(web-search): route every provider key and doctor through one placeholder rule

Review follow-up for #8206:

- provider_credentials exposes is_template_placeholder(), the single
  definition of an .env.example example value. key/token/secret must be
  a whole hyphen-separated word, so a real value such as your-monkey or
  your-passkey is no longer dropped.
- Serper, Serply, Sofya, SpecStream, Unbrowse, Webz, Brave, Tencent WSA
  and You.com resolve config and environment keys through
  resolve_api_key instead of private strip-and-send copies, so an old
  .env that still holds the template value no longer sends it.
- make doctor asks the same predicate: a placeholder key is reported as
  still holding the .env.example value instead of "set", and a literal
  placeholder in config.yaml falls back to the environment key as the
  runtime does.
- A census test keeps credential environment reads under community/
  wrapped in resolve_api_key.

* fix(web-search): match shipped placeholders exactly and mirror Browserless precedence in doctor

Second review follow-up for #8206:

- is_template_placeholder() matches exactly the your-... values that
  .env.example ships instead of a pattern, so a token an operator chose
  (a self-hosted Browserless TOKEN such as your-review-team-token) is
  sent again. A test keeps the set equal to the template.
- make doctor mirrors Browserless: a configured token field is
  authoritative, blank or not, and BROWSERLESS_TOKEN is read only when
  the field is omitted. A blank or placeholder token now warns instead
  of reporting the variable, cross-checked against the client.
- The keyless Jina and You.com providers stay ok but name JINA_API_KEY
  or YDC_API_KEY when a placeholder there is ignored, and a literal
  placeholder api_key in config.yaml is named when no env key backs it.

* fix(doctor): match each web provider's key precedence; drop the dead Jina api_key from docs

Found while re-checking #8206 against every provider's runtime:

- Browserless used as web_fetch had no doctor key check; it now gets the
  same authoritative token handling as web_capture.
- InfoQuest reads only INFOQUEST_API_KEY, so doctor no longer counts an
  api_key field in its tool config as configured.
- GroundRoute needs a key for every call but had no doctor entry, so a
  missing key reported ok.
- You.com names a placeholder api_key in config that it ignores.
- The Jina web_fetch docs snippet carried api_key: $JINA_API_KEY, which
  the tool never reads. With JINA_API_KEY now commented out in
  .env.example, that line made config loading fail on a fresh install.
- The provider_credentials docstring now scopes doctor's use of the
  predicate to web provider keys, and the scripts guide no longer says
  .env.example ships live placeholder keys.
2026-11-13 23:23:29 +08:00

467 lines
17 KiB
Python

"""Tests for JinaClient async crawl method."""
import json
import logging
from unittest.mock import MagicMock
import httpx
import pytest
import deerflow.community.jina_ai.jina_client as jina_client_module
from deerflow.community.jina_ai.jina_client import JinaClient
from deerflow.community.jina_ai.tools import (
_coerce_bool,
_coerce_proxy,
_coerce_timeout,
web_fetch_tool,
)
@pytest.fixture
def jina_client():
return JinaClient()
@pytest.mark.anyio
async def test_crawl_success(jina_client, monkeypatch):
"""Test successful crawl returns response text."""
async def mock_post(self, url, **kwargs):
return httpx.Response(200, text="<html><body>Hello</body></html>", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
result = await jina_client.crawl("https://example.com")
assert result == "<html><body>Hello</body></html>"
@pytest.mark.anyio
@pytest.mark.parametrize("max_retries", [0, 1])
async def test_crawl_follows_jina_api_redirect(jina_client, monkeypatch, max_retries):
"""A redirected Jina API endpoint should still return fetched content."""
requests = []
def handle(request: httpx.Request) -> httpx.Response:
assert json.loads(request.read()) == {"url": "https://example.com"}
requests.append(request)
if request.url.path == "/":
return httpx.Response(307, headers={"Location": "https://r.jina.ai/reader"})
return httpx.Response(200, text="Fetched page")
original_client = httpx.AsyncClient
transport = httpx.MockTransport(handle)
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original_client(transport=transport, **kwargs))
result = await jina_client.crawl("https://example.com", max_retries=max_retries)
assert result == "Fetched page"
assert [request.url.path for request in requests] == ["/", "/reader"]
assert all(request.method == "POST" for request in requests)
@pytest.mark.anyio
@pytest.mark.parametrize("max_retries", [0, 1])
async def test_crawl_strips_api_key_on_cross_host_redirect(jina_client, monkeypatch, max_retries):
"""A redirected host must not receive the Jina bearer credential."""
requests: list[httpx.Request] = []
def handle(request: httpx.Request) -> httpx.Response:
assert json.loads(request.read()) == {"url": "https://example.com"}
requests.append(request)
if request.url.host == "r.jina.ai":
return httpx.Response(307, headers={"Location": "https://redirect.example/final"})
return httpx.Response(200, text="Fetched page")
original_client = httpx.AsyncClient
transport = httpx.MockTransport(handle)
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original_client(transport=transport, **kwargs))
monkeypatch.setenv("JINA_API_KEY", "test-key")
result = await jina_client.crawl("https://example.com", max_retries=max_retries)
assert result == "Fetched page"
assert [request.url.host for request in requests] == ["r.jina.ai", "redirect.example"]
assert [request.method for request in requests] == ["POST", "POST"]
assert requests[0].headers["Authorization"] == "Bearer test-key"
assert "Authorization" not in requests[1].headers
@pytest.mark.anyio
async def test_crawl_non_200_status(jina_client, monkeypatch):
"""Test that non-200 status returns error message."""
async def mock_post(self, url, **kwargs):
return httpx.Response(429, text="Rate limited", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
result = await jina_client.crawl("https://example.com")
assert result.startswith("Error:")
assert "429" in result
@pytest.mark.anyio
async def test_crawl_empty_response(jina_client, monkeypatch):
"""Test that empty response returns error message."""
async def mock_post(self, url, **kwargs):
return httpx.Response(200, text="", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
result = await jina_client.crawl("https://example.com")
assert result.startswith("Error:")
assert "empty" in result.lower()
@pytest.mark.anyio
async def test_crawl_whitespace_only_response(jina_client, monkeypatch):
"""Test that whitespace-only response returns error message."""
async def mock_post(self, url, **kwargs):
return httpx.Response(200, text=" \n ", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
result = await jina_client.crawl("https://example.com")
assert result.startswith("Error:")
assert "empty" in result.lower()
@pytest.mark.anyio
async def test_crawl_network_error(jina_client, monkeypatch):
"""Test that network errors are handled gracefully."""
async def mock_post(self, url, **kwargs):
raise httpx.ConnectError("Connection refused")
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
result = await jina_client.crawl("https://example.com")
assert result.startswith("Error:")
assert "failed" in result.lower()
@pytest.mark.anyio
async def test_crawl_transient_failure_logs_without_traceback(jina_client, monkeypatch, caplog):
"""Transient network failures must log at WARNING without a traceback and include the exception type."""
# Keep the missing-key warning independent of test order and shard placement.
monkeypatch.setenv("JINA_API_KEY", "test-key")
async def mock_post(self, url, **kwargs):
raise httpx.ConnectTimeout("timed out")
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
with caplog.at_level(logging.DEBUG, logger="deerflow.community.jina_ai.jina_client"):
result = await jina_client.crawl("https://example.com")
jina_records = [r for r in caplog.records if r.name == "deerflow.community.jina_ai.jina_client" and r.levelno >= logging.WARNING]
assert len(jina_records) == 1, f"expected exactly one log record, got {len(jina_records)}"
record = jina_records[0]
assert record.levelno == logging.WARNING, f"expected WARNING, got {record.levelname}"
assert record.exc_info is None, "transient failures must not attach a traceback"
assert "ConnectTimeout" in record.getMessage()
assert result.startswith("Error:")
assert "ConnectTimeout" in result
@pytest.mark.anyio
async def test_crawl_passes_headers(jina_client, monkeypatch):
"""Test that correct headers are sent."""
captured_headers = {}
async def mock_post(self, url, **kwargs):
captured_headers.update(kwargs.get("headers", {}))
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
await jina_client.crawl("https://example.com", return_format="markdown", timeout=30)
assert captured_headers["X-Return-Format"] == "markdown"
assert captured_headers["X-Timeout"] == "30"
@pytest.mark.anyio
async def test_crawl_passes_proxy_to_httpx_client(jina_client, monkeypatch):
"""Explicit proxy config should be passed to httpx.AsyncClient."""
captured_client_kwargs = {}
class MockAsyncClient:
def __init__(self, **kwargs):
captured_client_kwargs.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, **kwargs):
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx, "AsyncClient", MockAsyncClient)
result = await jina_client.crawl("https://example.com", proxy="http://127.0.0.1:7890")
assert result == "ok"
assert captured_client_kwargs["proxy"] == "http://127.0.0.1:7890"
assert captured_client_kwargs["trust_env"] is True
assert captured_client_kwargs["follow_redirects"] is True
@pytest.mark.anyio
async def test_crawl_can_disable_trust_env(jina_client, monkeypatch):
"""Callers can disable environment proxy lookup for deterministic networking."""
captured_client_kwargs = {}
class MockAsyncClient:
def __init__(self, **kwargs):
captured_client_kwargs.update(kwargs)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, **kwargs):
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx, "AsyncClient", MockAsyncClient)
result = await jina_client.crawl("https://example.com", trust_env=False)
assert result == "ok"
assert captured_client_kwargs == {"trust_env": False, "follow_redirects": True}
@pytest.mark.anyio
async def test_crawl_includes_api_key_when_set(jina_client, monkeypatch):
"""Test that Authorization header is set when JINA_API_KEY is available."""
captured_headers = {}
async def mock_post(self, url, **kwargs):
captured_headers.update(kwargs.get("headers", {}))
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
monkeypatch.setenv("JINA_API_KEY", "test-key-123")
await jina_client.crawl("https://example.com")
assert captured_headers["Authorization"] == "Bearer test-key-123"
@pytest.mark.anyio
async def test_crawl_uses_the_anonymous_tier_when_env_holds_the_template_placeholder(jina_client, monkeypatch):
"""A fresh ``make config`` leaves ``JINA_API_KEY=your-jina-api-key``; sending it makes every fetch a 401."""
captured_headers = {}
async def mock_post(self, url, **kwargs):
captured_headers.update(kwargs.get("headers", {}))
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
monkeypatch.setenv("JINA_API_KEY", "your-jina-api-key")
assert await jina_client.crawl("https://example.com") == "ok"
assert "Authorization" not in captured_headers
@pytest.mark.anyio
async def test_crawl_warns_once_when_api_key_missing(jina_client, monkeypatch, caplog):
"""Test that the missing API key warning is logged only once."""
jina_client_module._api_key_warned = False
async def mock_post(self, url, **kwargs):
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
monkeypatch.delenv("JINA_API_KEY", raising=False)
with caplog.at_level(logging.WARNING, logger="deerflow.community.jina_ai.jina_client"):
await jina_client.crawl("https://example.com")
await jina_client.crawl("https://example.com")
warning_count = sum(1 for record in caplog.records if "Jina API key is not set" in record.message)
assert warning_count == 1
@pytest.mark.anyio
async def test_crawl_no_auth_header_without_api_key(jina_client, monkeypatch):
"""Test that no Authorization header is set when JINA_API_KEY is not available."""
jina_client_module._api_key_warned = False
captured_headers = {}
async def mock_post(self, url, **kwargs):
captured_headers.update(kwargs.get("headers", {}))
return httpx.Response(200, text="ok", request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", mock_post)
monkeypatch.delenv("JINA_API_KEY", raising=False)
await jina_client.crawl("https://example.com")
assert "Authorization" not in captured_headers
@pytest.mark.anyio
async def test_web_fetch_tool_returns_error_on_crawl_failure(monkeypatch):
"""Test that web_fetch_tool short-circuits and returns the error string when crawl fails."""
async def mock_crawl(self, url, **kwargs):
return "Error: Jina API returned status 429: Rate limited"
mock_config = MagicMock()
mock_config.get_tool_config.return_value = None
monkeypatch.setattr("deerflow.community.jina_ai.tools.get_app_config", lambda: mock_config)
monkeypatch.setattr(JinaClient, "crawl", mock_crawl)
result = await web_fetch_tool.ainvoke("https://example.com")
assert result.startswith("Error:")
assert "429" in result
@pytest.mark.anyio
async def test_web_fetch_tool_returns_markdown_on_success(monkeypatch):
"""Test that web_fetch_tool returns extracted markdown on successful crawl."""
async def mock_crawl(self, url, **kwargs):
return "<html><body><p>Hello world</p></body></html>"
mock_config = MagicMock()
mock_config.get_tool_config.return_value = None
monkeypatch.setattr("deerflow.community.jina_ai.tools.get_app_config", lambda: mock_config)
monkeypatch.setattr(JinaClient, "crawl", mock_crawl)
result = await web_fetch_tool.ainvoke("https://example.com")
assert "Hello world" in result
assert not result.startswith("Error:")
@pytest.mark.anyio
async def test_web_fetch_tool_forwards_proxy_and_trust_env(monkeypatch):
"""web_fetch tool config should be forwarded to JinaClient.crawl."""
captured_crawl_kwargs = {}
async def mock_crawl(self, url, **kwargs):
captured_crawl_kwargs.update(kwargs)
return "<html><body><p>Hello world</p></body></html>"
mock_config = MagicMock()
mock_tool_config = MagicMock()
mock_tool_config.model_extra = {
"timeout": "20",
"proxy": "http://host.docker.internal:7890",
"trust_env": "false",
}
mock_config.get_tool_config.return_value = mock_tool_config
monkeypatch.setattr("deerflow.community.jina_ai.tools.get_app_config", lambda: mock_config)
monkeypatch.setattr(JinaClient, "crawl", mock_crawl)
result = await web_fetch_tool.ainvoke("https://example.com")
assert "Hello world" in result
assert captured_crawl_kwargs == {
"return_format": "html",
"timeout": 20,
"proxy": "http://host.docker.internal:7890",
"trust_env": False,
}
@pytest.mark.anyio
async def test_web_fetch_tool_ignores_empty_proxy(monkeypatch):
"""Empty proxy values from unresolved env vars should not be passed to httpx."""
captured_crawl_kwargs = {}
async def mock_crawl(self, url, **kwargs):
captured_crawl_kwargs.update(kwargs)
return "<html><body><p>Hello world</p></body></html>"
mock_config = MagicMock()
mock_tool_config = MagicMock()
mock_tool_config.model_extra = {"proxy": " ", "trust_env": True}
mock_config.get_tool_config.return_value = mock_tool_config
monkeypatch.setattr("deerflow.community.jina_ai.tools.get_app_config", lambda: mock_config)
monkeypatch.setattr(JinaClient, "crawl", mock_crawl)
result = await web_fetch_tool.ainvoke("https://example.com")
assert "Hello world" in result
assert captured_crawl_kwargs["proxy"] is None
assert captured_crawl_kwargs["trust_env"] is True
@pytest.mark.anyio
async def test_web_fetch_tool_offloads_extraction_to_thread(monkeypatch):
"""Test that readability extraction is offloaded via asyncio.to_thread to avoid blocking the event loop."""
import asyncio
async def mock_crawl(self, url, **kwargs):
return "<html><body><p>threaded</p></body></html>"
mock_config = MagicMock()
mock_config.get_tool_config.return_value = None
monkeypatch.setattr("deerflow.community.jina_ai.tools.get_app_config", lambda: mock_config)
monkeypatch.setattr(JinaClient, "crawl", mock_crawl)
to_thread_called = False
original_to_thread = asyncio.to_thread
async def tracking_to_thread(func, *args, **kwargs):
nonlocal to_thread_called
to_thread_called = True
return await original_to_thread(func, *args, **kwargs)
monkeypatch.setattr("deerflow.community.jina_ai.tools.asyncio.to_thread", tracking_to_thread)
result = await web_fetch_tool.ainvoke("https://example.com")
assert to_thread_called, "extract_article must be called via asyncio.to_thread to avoid blocking the event loop"
assert "threaded" in result
@pytest.mark.parametrize(
("value", "default", "expected"),
[
(True, False, True),
(False, True, False),
("true", False, True),
("YES", False, True),
(" on ", False, True),
("1", False, True),
("false", True, False),
("No", True, False),
("off", True, False),
("0", True, False),
("maybe", True, True),
("maybe", False, False),
(None, True, True),
(123, False, False),
],
)
def test_coerce_bool(value, default, expected):
"""_coerce_bool normalizes booleans, known strings, and falls back to the default."""
assert _coerce_bool(value, default) is expected
@pytest.mark.parametrize(
("value", "default", "expected"),
[
(30, 10, 30),
("45", 10, 45),
("not-a-number", 10, 10),
(True, 10, 10),
(False, 10, 10),
(None, 10, 10),
(1.5, 10, 10),
],
)
def test_coerce_timeout(value, default, expected):
"""_coerce_timeout accepts ints and numeric strings, rejecting bools and junk."""
assert _coerce_timeout(value, default) == expected
@pytest.mark.parametrize(
("value", "expected"),
[
("http://127.0.0.1:7890", "http://127.0.0.1:7890"),
(" http://proxy:8080 ", "http://proxy:8080"),
("", None),
(" ", None),
(None, None),
(123, None),
],
)
def test_coerce_proxy(value, expected):
"""_coerce_proxy trims strings and treats empty/non-string values as None."""
assert _coerce_proxy(value) == expected