SERPent / tests /test_input_bounds.py
Claude
Move orchestration out of the route handlers into a service layer
e44fdef unverified
Raw
History Blame Contribute Delete
4.81 kB
"""Bounds on request-shaped input.
`n_results` was defended by a validator and a parametrized test while the
list fields on the same models were undefended entirely: an empty query
list crashed every search endpoint with an IndexError, and the bulk
endpoints would fan out one concurrent outbound request per id with no
upper limit at all.
"""
import httpx
import pytest
from httpx import ASGITransport
from pydantic import ValidationError
import app as app_module
import scrap as scrap_module
from app import MAX_BULK_PATENT_IDS, ScrapPatentsRequest
from serp import MAX_QUERIES_PER_REQUEST, SerpQuery
from services import shape_serp_results
@pytest.fixture
async def client():
transport = ASGITransport(app=app_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
# --------------------------------- query list bounds ---------------------------------
def test_empty_query_list_is_rejected_by_the_model():
with pytest.raises(ValidationError):
SerpQuery(queries=[])
def test_too_many_queries_are_rejected_by_the_model():
with pytest.raises(ValidationError):
SerpQuery(queries=["q"] * (MAX_QUERIES_PER_REQUEST + 1))
async def test_empty_query_list_returns_422_not_500(client):
"""`_shape_serp_results` read `results[-1]` to build its error message,
so an empty list raised IndexError - an unhandled 500 on all six search
endpoints, reachable with a two-character request body.
"""
resp = await client.post("/serp/search_arxiv", json={"queries": []})
assert resp.status_code == 422
async def test_oversized_query_list_returns_422(client):
resp = await client.post(
"/serp/search_arxiv", json={"queries": ["q"] * (MAX_QUERIES_PER_REQUEST + 1)})
assert resp.status_code == 422
def test_shape_serp_results_is_total_for_an_empty_list():
"""Defence in depth: the helper must not depend on its caller having
validated the query list, since it is shared by every search path.
"""
result = shape_serp_results([])
assert result.results == []
assert result.error is not None
# ---------------------------------- bulk id bounds ----------------------------------
def test_empty_patent_id_list_is_rejected():
with pytest.raises(ValidationError):
ScrapPatentsRequest(patent_ids=[])
def test_too_many_patent_ids_are_rejected():
with pytest.raises(ValidationError):
ScrapPatentsRequest(patent_ids=["US1234567"] * (MAX_BULK_PATENT_IDS + 1))
async def test_oversized_bulk_request_returns_422(client):
resp = await client.post(
"/scrap/scrap_patents_bulk",
json={"patent_ids": ["US1234567"] * (MAX_BULK_PATENT_IDS + 1)})
assert resp.status_code == 422
# ------------------------------- bulk fan-out is bounded -------------------------------
async def test_bulk_scrape_limits_concurrent_outbound_requests(monkeypatch):
"""Playwright work is bounded by a semaphore; HTTP work was not bounded
at all, so a single accepted request could open one outbound scrape per
id. Track how many are in flight simultaneously.
"""
import asyncio
in_flight = 0
peak = 0
async def slow_scrape(client_arg, url):
nonlocal in_flight, peak
in_flight += 1
peak = max(peak, in_flight)
await asyncio.sleep(0.01)
in_flight -= 1
raise httpx.ConnectTimeout("nope")
monkeypatch.setattr(scrap_module, "scrap_patent_async", slow_scrape)
ids = [f"US{1000000 + i}" for i in range(40)]
result = await scrap_module.scrap_patent_bulk_async(None, ids)
assert peak <= scrap_module.BULK_SCRAP_CONCURRENCY_LIMIT, (
f"{peak} concurrent scrapes for {len(ids)} ids; "
f"limit is {scrap_module.BULK_SCRAP_CONCURRENCY_LIMIT}")
assert result.failed_ids == ids
async def test_bulk_ops_retrieval_limits_concurrent_requests(monkeypatch):
"""The OPS bulk path is the expensive one - three requests per id
(biblio, claims, description) - so it carries a tighter limit.
"""
import asyncio
import ops as ops_module
in_flight = 0
peak = 0
async def slow_ops_scrap(client_arg, number, *args, **kwargs):
nonlocal in_flight, peak
in_flight += 1
peak = max(peak, in_flight)
await asyncio.sleep(0.01)
in_flight -= 1
raise RuntimeError("nope")
monkeypatch.setattr(ops_module, "ops_scrap_patent", slow_ops_scrap)
numbers = [f"US{1000000 + i}" for i in range(30)]
result = await ops_module.ops_scrap_patent_bulk(None, numbers)
assert peak <= ops_module.BULK_OPS_CONCURRENCY_LIMIT, (
f"{peak} concurrent OPS retrievals; limit is "
f"{ops_module.BULK_OPS_CONCURRENCY_LIMIT}")
assert result.failed_ids == numbers