Files
roboco/tests/unit/services/test_research.py
T

315 lines
10 KiB
Python
Raw Normal View History

"""roboco.services.research — provider adapters + service coverage.
Provider HTTP is exercised against ``httpx.MockTransport`` (no network, no
extra dependency); the service-level tests use a recording fake to assert the
result/byte clamps.
"""
from __future__ import annotations
import json
from collections.abc import Callable
import httpx
import pytest
from roboco.config import settings
from roboco.services.research import (
BraveProvider,
ExaProvider,
FetchOutcome,
NullProvider,
ResearchError,
ResearchService,
ResearchUnsupportedError,
SearchOutcome,
SearchProvider,
TavilyProvider,
build_provider,
get_research_service,
)
Handler = Callable[[httpx.Request], httpx.Response]
_QUERY = "agentic frameworks"
_N_RESULTS = 3
_TOP_SCORE = 0.9
_TRUNC_CAP = 10
_RESULTS_CAP = 5
_REQ_RESULTS = 2
_FETCH_CAP = 20
def _client(handler: Handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
# --------------------------------------------------------------------------- #
# Tavily
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_tavily_search_parses_results_and_answer() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.host == "api.tavily.com"
body = json.loads(request.content)
assert body["query"] == _QUERY
assert body["max_results"] == _N_RESULTS
return httpx.Response(
200,
json={
"query": _QUERY,
"answer": "Several exist.",
"results": [
{
"title": "A",
"url": "https://a.test",
"content": "sa",
"score": 0.9,
},
{
"title": "B",
"url": "https://b.test",
"content": "sb",
"score": 0.5,
},
],
},
)
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
out = await provider.search(_QUERY, _N_RESULTS)
assert out.provider == "tavily"
assert out.answer == "Several exist."
assert [h.url for h in out.hits] == ["https://a.test", "https://b.test"]
assert out.hits[0].score == _TOP_SCORE
await client.aclose()
@pytest.mark.asyncio
async def test_tavily_fetch_extracts_raw_content() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/extract"
return httpx.Response(
200, json={"results": [{"url": "https://a.test", "raw_content": "hello"}]}
)
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
out = await provider.fetch("https://a.test", 1000)
assert out.content == "hello"
assert out.truncated is False
await client.aclose()
@pytest.mark.asyncio
async def test_tavily_fetch_truncates_to_cap() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, json={"results": [{"url": "u", "raw_content": "x" * 100}]}
)
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
out = await provider.fetch("u", _TRUNC_CAP)
assert len(out.content) == _TRUNC_CAP
assert out.truncated is True
await client.aclose()
# --------------------------------------------------------------------------- #
# Brave
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_brave_search_parses_web_results() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.host == "api.search.brave.com"
assert request.headers["X-Subscription-Token"] == "k"
return httpx.Response(
200,
json={
"web": {
"results": [
{"title": "T", "url": "https://t.test", "description": "d"}
]
}
},
)
client = _client(handler)
provider = BraveProvider(api_key="k", timeout=5.0, client=client)
out = await provider.search("q", _RESULTS_CAP)
assert out.provider == "brave"
assert out.answer is None
assert out.hits[0].snippet == "d"
await client.aclose()
@pytest.mark.asyncio
async def test_brave_fetch_is_unsupported() -> None:
provider = BraveProvider(
api_key="k", timeout=5.0, client=_client(lambda _r: httpx.Response(200))
)
with pytest.raises(ResearchUnsupportedError):
await provider.fetch("https://x.test", 100)
# --------------------------------------------------------------------------- #
# Exa
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_exa_search_and_fetch() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/search":
return httpx.Response(
200,
json={
"results": [{"title": "E", "url": "https://e.test", "text": "snip"}]
},
)
return httpx.Response(
200, json={"results": [{"url": "https://e.test", "text": "full"}]}
)
client = _client(handler)
provider = ExaProvider(api_key="k", timeout=5.0, client=client)
out = await provider.search("q", _RESULTS_CAP)
assert out.hits[0].snippet == "snip"
fetched = await provider.fetch("https://e.test", 1000)
assert fetched.content == "full"
await client.aclose()
# --------------------------------------------------------------------------- #
# Error handling
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_non_2xx_raises_research_error() -> None:
client = _client(lambda _r: httpx.Response(500, text="boom"))
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
with pytest.raises(ResearchError):
await provider.search("q", _RESULTS_CAP)
await client.aclose()
@pytest.mark.asyncio
async def test_network_error_raises_research_error() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("down")
client = _client(handler)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
with pytest.raises(ResearchError):
await provider.search("q", _RESULTS_CAP)
await client.aclose()
@pytest.mark.asyncio
async def test_malformed_json_raises_research_error() -> None:
client = _client(
lambda _r: httpx.Response(
200, text="not json", headers={"content-type": "application/json"}
)
)
provider = TavilyProvider(api_key="k", timeout=5.0, client=client)
with pytest.raises(ResearchError):
await provider.search("q", _RESULTS_CAP)
await client.aclose()
# --------------------------------------------------------------------------- #
# NullProvider + build_provider
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_null_provider_degrades_gracefully() -> None:
provider = NullProvider(api_key=None, timeout=5.0)
assert provider.configured is False
search = await provider.search("q", _RESULTS_CAP)
assert search.hits == []
assert search.provider == "null"
fetched = await provider.fetch("u", _RESULTS_CAP)
assert fetched.content == ""
def test_build_provider_selects_by_name() -> None:
assert isinstance(build_provider("tavily", "k", 5.0), TavilyProvider)
assert isinstance(build_provider("brave", "k", 5.0), BraveProvider)
assert isinstance(build_provider("exa", "k", 5.0), ExaProvider)
assert isinstance(build_provider("null", "k", 5.0), NullProvider)
def test_build_provider_null_when_no_key_or_unknown() -> None:
assert isinstance(build_provider("tavily", None, 5.0), NullProvider)
assert isinstance(build_provider("mystery", "k", 5.0), NullProvider)
# --------------------------------------------------------------------------- #
# ResearchService — clamps
# --------------------------------------------------------------------------- #
class _RecordingProvider(SearchProvider):
name = "rec"
def __init__(self) -> None:
super().__init__(api_key="k", timeout=5.0)
self.last_max_results: int | None = None
self.last_max_chars: int | None = None
self.fetch_content = "z" * 50
async def search(self, query: str, max_results: int) -> SearchOutcome:
self.last_max_results = max_results
return SearchOutcome(query=query, hits=[], answer=None, provider=self.name)
async def fetch(self, url: str, max_chars: int) -> FetchOutcome:
self.last_max_chars = max_chars
return FetchOutcome(
url=url, content=self.fetch_content, truncated=False, provider=self.name
)
@pytest.mark.asyncio
async def test_service_clamps_max_results() -> None:
provider = _RecordingProvider()
service = ResearchService(
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=100
)
await service.search("q", 100)
assert provider.last_max_results == _RESULTS_CAP
await service.search("q", None)
assert provider.last_max_results == _RESULTS_CAP
await service.search("q", _REQ_RESULTS)
assert provider.last_max_results == _REQ_RESULTS
await service.search("q", 0)
assert provider.last_max_results == 1
@pytest.mark.asyncio
async def test_service_clamps_and_truncates_fetch() -> None:
provider = _RecordingProvider()
provider.fetch_content = "y" * 80
service = ResearchService(
provider, max_results_cap=_RESULTS_CAP, fetch_max_chars_cap=_FETCH_CAP
)
out = await service.fetch("u", 1000)
assert provider.last_max_chars == _FETCH_CAP
assert len(out.content) == _FETCH_CAP
assert out.truncated is True
def test_get_research_service_uses_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "research_provider", "null")
monkeypatch.setattr(settings, "research_api_key", None)
service = get_research_service()
assert service.provider_name == "null"
assert service.configured is False