Files
2026-07-13 13:12:33 +08:00

277 lines
8.5 KiB
Python

from __future__ import annotations
import json
import httpx
import pytest
from opensquilla.search.providers.iqs import IqsSearchProvider
from opensquilla.search.registry import get_provider_spec
from opensquilla.search.types import SearchProviderError
_UNIFIED_URL = "https://cloud-iqs.aliyuncs.com/search/unified"
@pytest.mark.asyncio
async def test_iqs_search_posts_unified_request_and_maps_results() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={
"requestId": "req-iqs-1",
"pageItems": [
{
"title": "IQS result",
"link": "https://example.cn/a",
"snippet": "Snippet text",
"mainText": "Main text body",
"publishedTime": "2026-03-10T23:21:00+08:00",
"rerankScore": 0.875,
"hostname": "Example CN",
"images": [],
}
],
"sceneItems": [],
"searchInformation": {"searchTime": 412},
"costCredits": {"search": {"liteAdvancedTextSearch": 1}},
},
)
provider = IqsSearchProvider(
api_key="dummy-iqs-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) == _UNIFIED_URL
assert requests[0].headers["Authorization"] == "Bearer dummy-iqs-key"
assert requests[0].headers["Content-Type"] == "application/json"
body = json.loads(requests[0].content)
assert body == {
"query": "OpenSquilla",
"engineType": "LiteAdvanced",
"contents": {"mainText": True, "rerankScore": True},
"advancedParams": {"numResults": 3},
"timeRange": "OneYear",
}
result = results[0]
assert result.provider == "iqs"
assert result.source == "iqs"
assert result.title == "IQS result"
assert result.url == "https://example.cn/a"
assert result.snippet == "Snippet text"
assert result.content == "Main text body"
assert result.published_at == "2026-03-10T23:21:00+08:00"
assert result.score == 0.875
assert result.raw_metadata["requestId"] == "req-iqs-1"
assert result.raw_metadata["hostname"] == "Example CN"
assert "mainText" not in result.raw_metadata
@pytest.mark.asyncio
async def test_iqs_search_sends_domain_filters() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"requestId": "req-iqs-2", "pageItems": []})
provider = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
await provider.search(
"OpenSquilla",
max_results=5,
include_domains=("example.com", "example.org"),
exclude_domains=("spam.example",),
)
body = json.loads(requests[0].content)
assert body["advancedParams"] == {
"numResults": 5,
"includeSites": "example.com,example.org",
"excludeSites": "spam.example",
}
@pytest.mark.asyncio
async def test_iqs_search_clamps_num_results_to_provider_limit() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"requestId": "req-iqs-3", "pageItems": []})
provider = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
await provider.search("OpenSquilla", max_results=40)
body = json.loads(requests[0].content)
assert body["advancedParams"]["numResults"] == 20
@pytest.mark.asyncio
async def test_iqs_search_truncates_overlong_query() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"requestId": "req-iqs-4", "pageItems": []})
provider = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
await provider.search("q" * 600)
body = json.loads(requests[0].content)
assert body["query"] == "q" * 500
@pytest.mark.asyncio
async def test_iqs_missing_api_key_raises_auth_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("IQS_SEARCH_API_KEY", raising=False)
provider = IqsSearchProvider()
with pytest.raises(SearchProviderError) as exc_info:
await provider.search("OpenSquilla")
assert exc_info.value.provider == "iqs"
assert exc_info.value.kind == "auth"
assert exc_info.value.retryable is False
assert str(exc_info.value) == "IQS API key not set"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status_code", "kind", "retryable"),
[
(401, "auth", False),
(403, "auth", False),
(404, "auth", False),
(429, "rate_limit", True),
(500, "http", True),
],
)
async def test_iqs_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 = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
with pytest.raises(SearchProviderError) as exc_info:
await provider.search("OpenSquilla")
assert exc_info.value.provider == "iqs"
assert exc_info.value.kind == kind
assert exc_info.value.retryable is retryable
assert exc_info.value.status_code == status_code
@pytest.mark.asyncio
async def test_iqs_http_error_message_includes_error_detail() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
403,
json={
"errorCode": "Retrieval.InvalidAPIKey",
"errorMessage": "Incorrect APIKey provided.",
},
)
provider = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
with pytest.raises(SearchProviderError) as exc_info:
await provider.search("OpenSquilla")
assert exc_info.value.kind == "auth"
assert "Retrieval.InvalidAPIKey" in str(exc_info.value)
assert "Incorrect APIKey provided." in str(exc_info.value)
@pytest.mark.asyncio
async def test_iqs_http_error_with_non_json_body_is_classified() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(400, text="EngineType deserialization failed")
provider = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
with pytest.raises(SearchProviderError) as exc_info:
await provider.search("OpenSquilla")
assert exc_info.value.kind == "http"
assert exc_info.value.retryable is True
assert exc_info.value.status_code == 400
assert "EngineType deserialization failed" in str(exc_info.value)
@pytest.mark.asyncio
async def test_iqs_malformed_json_raises_parse_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="not json")
provider = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
with pytest.raises(SearchProviderError) as exc_info:
await provider.search("OpenSquilla")
assert exc_info.value.kind == "parse"
assert exc_info.value.retryable is False
@pytest.mark.asyncio
async def test_iqs_missing_page_items_returns_empty_results() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"requestId": "req-iqs-5"})
provider = IqsSearchProvider(
api_key="dummy-iqs-key",
transport=httpx.MockTransport(handler),
)
results = await provider.search("OpenSquilla")
assert results == []
def test_iqs_provider_spec_is_runtime_supported_after_import() -> None:
import opensquilla.search.providers.iqs # noqa: F401
spec = get_provider_spec("iqs")
assert spec.runtime_supported is True
assert spec.requires_api_key is True
assert spec.env_key == "IQS_SEARCH_API_KEY"
assert spec.capabilities == frozenset({"web", "freshness", "domain_filter", "content"})