SERPent / tests /test_ops_client.py
Claude
Add pytest harness with characterization tests for scrap/ops/serp/utils
5796881 unverified
Raw
History Blame Contribute Delete
7.2 kB
import json
from pathlib import Path
import httpx
import pytest
import respx
import ops as ops_module
from ops import OPSNotConfigured, _OPSTokenManager, _ops_get, ops_scrap_patent, ops_search
FIXTURES = Path(__file__).parent / "fixtures" / "ops"
TOKEN_URL = "https://ops.epo.org/3.2/auth/accesstoken"
OPS_BASE = "https://ops.epo.org/3.2/rest-services"
def load_json(name: str):
return json.loads((FIXTURES / name).read_text())
def mock_token_endpoint():
return respx.post(TOKEN_URL).mock(
return_value=httpx.Response(200, json=load_json("token_response.json"))
)
@pytest.fixture
def fresh_token_manager(monkeypatch):
"""A token manager with test credentials, swapped in for the module-level
singleton so `_ops_get`/`ops_search`/etc. (which reach for the module
global `token_manager` directly) pick it up.
"""
tm = _OPSTokenManager()
tm._key = "test-key"
tm._secret = "test-secret"
monkeypatch.setattr(ops_module, "token_manager", tm)
return tm
class TestTokenManager:
async def test_not_configured_raises(self):
tm = _OPSTokenManager()
tm._key = None
tm._secret = None
async with httpx.AsyncClient() as client:
with pytest.raises(OPSNotConfigured):
await tm.get_token(client)
async def test_fetches_and_caches_the_token(self):
tm = _OPSTokenManager()
tm._key = "k"
tm._secret = "s"
with respx.mock:
route = mock_token_endpoint()
async with httpx.AsyncClient() as client:
token1 = await tm.get_token(client)
token2 = await tm.get_token(client)
assert token1 == "test-access-token-123"
assert token2 == "test-access-token-123"
assert route.call_count == 1
async def test_force_refresh_refetches_even_if_cached(self):
tm = _OPSTokenManager()
tm._key = "k"
tm._secret = "s"
with respx.mock:
route = mock_token_endpoint()
async with httpx.AsyncClient() as client:
await tm.get_token(client)
await tm.get_token(client, force=True)
assert route.call_count == 2
class TestOpsGet:
async def test_retries_once_when_the_token_is_stale(self, fresh_token_manager):
with respx.mock:
mock_token_endpoint()
get_route = respx.get(f"{OPS_BASE}/some/path").mock(
side_effect=[
httpx.Response(401, text="Invalid access token."),
httpx.Response(200, json={"ok": True}),
]
)
async with httpx.AsyncClient() as client:
data = await _ops_get(client, "/some/path")
assert data == {"ok": True}
assert get_route.call_count == 2
async def test_propagates_non_token_errors(self, fresh_token_manager):
with respx.mock:
mock_token_endpoint()
respx.get(f"{OPS_BASE}/some/path").mock(return_value=httpx.Response(500, text="server error"))
async with httpx.AsyncClient() as client:
with pytest.raises(httpx.HTTPStatusError):
await _ops_get(client, "/some/path")
class TestOpsSearch:
async def test_maps_ops_search_results_to_serp_dicts(self, fresh_token_manager):
with respx.mock:
mock_token_endpoint()
respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
return_value=httpx.Response(200, json=load_json("search_response.json"))
)
async with httpx.AsyncClient() as client:
results = await ops_search(client, "widget", 10)
assert results == [
{
"title": "Widget apparatus",
"body": "An apparatus comprising a widget.",
"href": "https://worldwide.espacenet.com/patent/search?q=US11930446",
"id": "US11930446",
},
{
"title": "Gadget system",
"body": "A system comprising a gadget.",
"href": "https://worldwide.espacenet.com/patent/search?q=EP4760514",
"id": "EP4760514",
},
]
async def test_returns_empty_list_on_404_not_found(self, fresh_token_manager):
with respx.mock:
mock_token_endpoint()
respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
return_value=httpx.Response(404, json=load_json("search_not_found_response.json"))
)
async with httpx.AsyncClient() as client:
results = await ops_search(client, "nonexistent widget xyz", 10)
assert results == []
class TestOpsScrapPatent:
async def test_assembles_a_full_patent_from_three_endpoints(self, fresh_token_manager):
number = "US11930446"
with respx.mock:
mock_token_endpoint()
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
return_value=httpx.Response(200, json=load_json("biblio_response.json"))
)
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
return_value=httpx.Response(200, json=load_json("claims_response.json"))
)
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
return_value=httpx.Response(200, json=load_json("description_response.json"))
)
async with httpx.AsyncClient() as client:
# Kind-code suffix ("B2") should be normalized away before
# any of the three requests go out.
result = await ops_scrap_patent(client, "US11930446B2")
assert result.title == "Widget apparatus"
assert result.abstract == "An apparatus comprising a widget."
assert result.claims == "1. A widget comprising a gadget.\n2. The widget of claim 1, further comprising a mechanism."
assert result.description == "This invention relates to widgets.\nThe widget comprises a gadget and a mechanism."
assert {c.code for c in result.classifications} == {"G06F17/30", "H04L9/00"}
async def test_missing_fulltext_degrades_to_none_instead_of_raising(self, fresh_token_manager):
number = "US11930446"
with respx.mock:
mock_token_endpoint()
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
return_value=httpx.Response(200, json=load_json("biblio_response.json"))
)
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
return_value=httpx.Response(404)
)
respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
return_value=httpx.Response(404)
)
async with httpx.AsyncClient() as client:
result = await ops_scrap_patent(client, number)
assert result.title == "Widget apparatus"
assert result.claims is None
assert result.description is None