187 lines
5.9 KiB
Python
187 lines
5.9 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from opensquilla.search.providers.bocha import BochaSearchProvider
|
|
from opensquilla.search.registry import get_provider_spec
|
|
from opensquilla.search.types import SearchProviderError
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bocha_search_posts_summary_request_and_maps_results() -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"code": 200,
|
|
"msg": "success",
|
|
"log_id": "log-bocha-1",
|
|
"data": {
|
|
"webPages": {
|
|
"value": [
|
|
{
|
|
"id": "https://example.cn/a",
|
|
"name": "Bocha result",
|
|
"url": "https://example.cn/a",
|
|
"snippet": "Snippet text",
|
|
"summary": "Summary text",
|
|
"siteName": "Example CN",
|
|
"displayUrl": "example.cn/a",
|
|
"datePublished": "2026-03-10T23:21:00+08:00",
|
|
"dateLastCrawled": "2026-03-11T08:00:00+08:00",
|
|
}
|
|
]
|
|
}
|
|
},
|
|
},
|
|
)
|
|
|
|
provider = BochaSearchProvider(
|
|
api_key="dummy-bocha-key",
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
results = await provider.search("OpenSquilla", max_results=3, recency="year")
|
|
|
|
assert len(requests) == 1
|
|
assert requests[0].method == "POST"
|
|
assert str(requests[0].url) == "https://api.bochaai.com/v1/web-search"
|
|
assert requests[0].headers["Authorization"] == "Bearer dummy-bocha-key"
|
|
assert requests[0].headers["Content-Type"] == "application/json"
|
|
body = json.loads(requests[0].content)
|
|
assert body == {
|
|
"query": "OpenSquilla",
|
|
"count": 3,
|
|
"summary": True,
|
|
"freshness": "oneYear",
|
|
}
|
|
|
|
result = results[0]
|
|
assert result.provider == "bocha"
|
|
assert result.source == "bocha"
|
|
assert result.title == "Bocha result"
|
|
assert result.url == "https://example.cn/a"
|
|
assert result.snippet == "Snippet text"
|
|
assert result.content == "Summary text"
|
|
assert result.published_at == "2026-03-10T23:21:00+08:00"
|
|
assert result.raw_metadata["log_id"] == "log-bocha-1"
|
|
assert result.raw_metadata["siteName"] == "Example CN"
|
|
assert result.raw_metadata["displayUrl"] == "example.cn/a"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bocha_search_clamps_count_to_provider_limit() -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
return httpx.Response(200, json={"code": 200, "data": {"webPages": {"value": []}}})
|
|
|
|
provider = BochaSearchProvider(
|
|
api_key="dummy-bocha-key",
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
await provider.search("OpenSquilla", max_results=40)
|
|
|
|
body = json.loads(requests[0].content)
|
|
assert body["count"] == 20
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bocha_missing_api_key_raises_auth_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("BOCHA_SEARCH_API_KEY", raising=False)
|
|
provider = BochaSearchProvider()
|
|
|
|
with pytest.raises(SearchProviderError) as exc_info:
|
|
await provider.search("OpenSquilla")
|
|
|
|
assert exc_info.value.provider == "bocha"
|
|
assert exc_info.value.kind == "auth"
|
|
assert exc_info.value.retryable is False
|
|
assert str(exc_info.value) == "Bocha API key not set"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("status_code", "kind", "retryable"),
|
|
[
|
|
(401, "auth", False),
|
|
(403, "auth", False),
|
|
(429, "rate_limit", True),
|
|
(500, "http", True),
|
|
],
|
|
)
|
|
async def test_bocha_http_errors_are_classified(
|
|
status_code: int,
|
|
kind: str,
|
|
retryable: bool,
|
|
) -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(status_code, json={"error": "nope"})
|
|
|
|
provider = BochaSearchProvider(
|
|
api_key="dummy-bocha-key",
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
with pytest.raises(SearchProviderError) as exc_info:
|
|
await provider.search("OpenSquilla")
|
|
|
|
assert exc_info.value.provider == "bocha"
|
|
assert exc_info.value.kind == kind
|
|
assert exc_info.value.retryable is retryable
|
|
assert exc_info.value.status_code == status_code
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("api_code", "kind", "retryable", "status_code"),
|
|
[
|
|
("401", "auth", False, 401),
|
|
("429", "rate_limit", True, 429),
|
|
("500", "http", True, 500),
|
|
],
|
|
)
|
|
async def test_bocha_api_error_codes_are_classified(
|
|
api_code: str,
|
|
kind: str,
|
|
retryable: bool,
|
|
status_code: int,
|
|
) -> None:
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"code": api_code, "msg": "bocha error"})
|
|
|
|
provider = BochaSearchProvider(
|
|
api_key="dummy-bocha-key",
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
with pytest.raises(SearchProviderError) as exc_info:
|
|
await provider.search("OpenSquilla")
|
|
|
|
assert exc_info.value.provider == "bocha"
|
|
assert exc_info.value.kind == kind
|
|
assert exc_info.value.retryable is retryable
|
|
assert exc_info.value.status_code == status_code
|
|
assert str(exc_info.value) == "bocha error"
|
|
|
|
|
|
def test_bocha_provider_spec_is_runtime_supported_after_import() -> None:
|
|
import opensquilla.search.providers.bocha # noqa: F401
|
|
|
|
spec = get_provider_spec("bocha")
|
|
|
|
assert spec.runtime_supported is True
|
|
assert spec.requires_api_key is True
|
|
assert spec.env_key == "BOCHA_SEARCH_API_KEY"
|
|
assert spec.capabilities == frozenset({"web", "freshness", "content"})
|