File size: 4,807 Bytes
d4440e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e44fdef
d4440e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e44fdef
d4440e7
e44fdef
d4440e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""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