Spaces:
Running
Running
| """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 | |
| 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 | |