"""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