scholarr/tests/unit/services/domains/arxiv/test_client.py

202 lines
7.5 KiB
Python

from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.domains.arxiv import client as arxiv_client_module
from app.services.domains.arxiv.client import ArxivClient
from app.services.domains.arxiv.errors import ArxivClientValidationError, ArxivRateLimitError
from app.services.domains.arxiv.rate_limit import ArxivCooldownStatus
_CLIENT_FEED_XML = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"
xmlns:arxiv="http://arxiv.org/schemas/atom">
<opensearch:totalResults>1</opensearch:totalResults>
<opensearch:startIndex>0</opensearch:startIndex>
<opensearch:itemsPerPage>1</opensearch:itemsPerPage>
<entry>
<id>http://arxiv.org/abs/9999.0001</id>
<title>Client Entry</title>
<summary>Client summary</summary>
</entry>
</feed>
"""
@pytest.mark.asyncio
async def test_client_search_builds_query_and_sort_params() -> None:
captured: dict[str, object] = {}
async def _request_fn(*, params, request_email, timeout_seconds):
captured["params"] = params
captured["request_email"] = request_email
captured["timeout_seconds"] = timeout_seconds
return httpx.Response(200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query"))
client = ArxivClient(request_fn=_request_fn)
feed = await client.search(
query='ti:"test"',
start=5,
max_results=7,
sort_by="submittedDate",
sort_order="ascending",
request_email="user@example.com",
timeout_seconds=3.5,
)
assert feed.opensearch.total_results == 1
assert captured["params"] == {
"search_query": 'ti:"test"',
"start": 5,
"max_results": 7,
"sortBy": "submittedDate",
"sortOrder": "ascending",
}
assert captured["request_email"] == "user@example.com"
assert captured["timeout_seconds"] == 3.5
@pytest.mark.asyncio
async def test_client_lookup_ids_builds_id_list_param() -> None:
captured: dict[str, object] = {}
async def _request_fn(*, params, request_email, timeout_seconds):
captured["params"] = params
return httpx.Response(200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query"))
client = ArxivClient(request_fn=_request_fn)
await client.lookup_ids(id_list=["1234.5678", " 9999.0001 "], start=0, max_results=2)
assert captured["params"] == {"id_list": "1234.5678,9999.0001", "start": 0, "max_results": 2}
@pytest.mark.asyncio
async def test_client_search_rejects_invalid_sort_by() -> None:
async def _unused_request_fn(*, params, request_email, timeout_seconds):
return httpx.Response(200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query"))
client = ArxivClient(request_fn=_unused_request_fn)
with pytest.raises(ArxivClientValidationError):
await client.search(query="x", sort_by="bad")
@pytest.mark.asyncio
async def test_client_lookup_ids_rejects_empty_list() -> None:
async def _unused_request_fn(*, params, request_email, timeout_seconds):
return httpx.Response(200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query"))
client = ArxivClient(request_fn=_unused_request_fn)
with pytest.raises(ArxivClientValidationError):
await client.lookup_ids(id_list=[])
@pytest.mark.asyncio
async def test_client_search_rejects_negative_start() -> None:
async def _unused_request_fn(*, params, request_email, timeout_seconds):
return httpx.Response(200, text=_CLIENT_FEED_XML, request=httpx.Request("GET", "https://export.arxiv.org/api/query"))
client = ArxivClient(request_fn=_unused_request_fn)
with pytest.raises(ArxivClientValidationError):
await client.search(query="ti:test", start=-1)
@pytest.mark.asyncio
async def test_client_propagates_http_status_error() -> None:
async def _request_fn(*, params, request_email, timeout_seconds):
request = httpx.Request("GET", "https://export.arxiv.org/api/query")
return httpx.Response(500, text="error", request=request)
client = ArxivClient(request_fn=_request_fn)
with pytest.raises(httpx.HTTPStatusError):
await client.search(query="ti:test")
@pytest.mark.asyncio
async def test_client_coalesces_concurrent_identical_search_requests() -> None:
calls = {"count": 0}
async def _request_fn(*, params, request_email, timeout_seconds):
calls["count"] += 1
await asyncio.sleep(0.05)
request = httpx.Request("GET", "https://export.arxiv.org/api/query")
return httpx.Response(200, text=_CLIENT_FEED_XML, request=request)
client = ArxivClient(request_fn=_request_fn)
await asyncio.gather(
client.search(query="ti:test"),
client.search(query="ti:test"),
)
assert calls["count"] == 1
@pytest.mark.asyncio
async def test_client_logs_cache_hit_and_miss(
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_ = db_session
calls = {"count": 0}
logged: list[dict[str, object]] = []
async def _request_fn(*, params, request_email, timeout_seconds):
calls["count"] += 1
request = httpx.Request("GET", "https://export.arxiv.org/api/query")
return httpx.Response(200, text=_CLIENT_FEED_XML, request=request)
def _capture_log(_msg: str, *args, **kwargs) -> None:
extra = kwargs.get("extra")
if isinstance(extra, dict):
logged.append(extra)
monkeypatch.setattr("app.services.domains.arxiv.client.logger.info", _capture_log)
client = ArxivClient(request_fn=_request_fn, cache_enabled=True)
await client.search(query="ti:test-cache")
await client.search(query="ti:test-cache")
events = [str(entry.get("event", "")) for entry in logged]
assert "arxiv.cache_miss" in events
assert "arxiv.cache_hit" in events
assert calls["count"] == 1
@pytest.mark.asyncio
async def test_request_feed_skips_live_call_when_global_cooldown_is_active(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logged: list[dict[str, object]] = []
called = {"count": 0}
async def _cooldown_status(*, now_utc=None):
_ = now_utc
return ArxivCooldownStatus(
is_active=True,
remaining_seconds=61.0,
cooldown_until=datetime(2026, 2, 26, 12, 1, tzinfo=timezone.utc),
)
async def _unexpected_limit_call(*, fetch, source_path): # pragma: no cover - defensive
_ = (fetch, source_path)
called["count"] += 1
return httpx.Response(200, text=_CLIENT_FEED_XML)
def _capture_warning(_msg: str, *args, **kwargs) -> None:
extra = kwargs.get("extra")
if isinstance(extra, dict):
logged.append(extra)
monkeypatch.setattr(arxiv_client_module, "get_arxiv_cooldown_status", _cooldown_status)
monkeypatch.setattr(arxiv_client_module, "run_with_global_arxiv_limit", _unexpected_limit_call)
monkeypatch.setattr("app.services.domains.arxiv.client.logger.warning", _capture_warning)
with pytest.raises(ArxivRateLimitError):
await arxiv_client_module._request_arxiv_feed(
params={"search_query": 'ti:"test"'},
request_email="user@example.com",
timeout_seconds=2.0,
)
assert called["count"] == 0
assert [entry.get("event") for entry in logged] == ["arxiv.request_skipped_cooldown"]