Almaatla commited on
Commit
632b2d9
·
unverified ·
2 Parent(s): a60451372a7212

Fix the defects a mutation probe found, and the architecture that hid them (#6)

Browse files

A mutation probe of the existing suite killed 5 of 6 mutants aimed at pure
functions and 1 of 7 aimed at the I/O boundary - deleting the API-key
middleware entirely left it green, as did replacing the Bing search URL
with a constant. Line coverage read 93% and could not see the difference.

Fixes every defect that hid behind that gap: the blocking DuckDuckGo call
that froze the event loop on the primary search path, an empty query list
500ing all six search endpoints, a non-ASCII API key crashing the auth
check, upstream failures reported as "patent not found" (which
MCP_INSTRUCTIONS tells agents to believe and not retry), unbounded bulk
fan-out, and three MCP tools advertising no output schema.

Then the cause: orchestration policy moved out of the route handlers into
services.py, with its stateful collaborators injected via Depends. Also
adds ruff to CI, closes the httpx client in the lifespan, and stops the
circuit breaker treating "zero results" as evidence a backend is down.

97 -> 238 tests, app.py 65% -> 97% coverage, mutants killed 6/13 -> 18/18.

.github/workflows/deploy-to-hf.yml CHANGED
@@ -24,6 +24,9 @@ jobs:
24
  - name: Install Playwright's Chromium
25
  run: playwright install --with-deps chromium
26
 
 
 
 
27
  - name: Run tests
28
  run: pytest
29
 
 
24
  - name: Install Playwright's Chromium
25
  run: playwright install --with-deps chromium
26
 
27
+ - name: Lint
28
+ run: ruff check .
29
+
30
  - name: Run tests
31
  run: pytest
32
 
.github/workflows/test.yml CHANGED
@@ -29,5 +29,8 @@ jobs:
29
  - name: Install Playwright's Chromium
30
  run: playwright install --with-deps chromium
31
 
 
 
 
32
  - name: Run tests
33
  run: pytest
 
29
  - name: Install Playwright's Chromium
30
  run: playwright install --with-deps chromium
31
 
32
+ - name: Lint
33
+ run: ruff check .
34
+
35
  - name: Run tests
36
  run: pytest
.gitignore CHANGED
@@ -1,4 +1,7 @@
1
  __pycache__
2
  .vscode
3
  .venv
4
- .env
 
 
 
 
1
  __pycache__
2
  .vscode
3
  .venv
4
+ .env
5
+ .coverage
6
+ htmlcov
7
+ .ruff_cache
CONTRIBUTING.md CHANGED
@@ -26,10 +26,11 @@ dependency update broke this."
26
  ## Running tests
27
 
28
  ```bash
 
29
  pytest
30
  ```
31
 
32
- 97 tests, a few seconds, fully offline. All outbound HTTP is mocked with
33
  `respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
34
  Google Patents search) run against a real headless Chromium but navigate to
35
  local fixture HTML instead of the live sites — see `tests/helpers.py` for
@@ -54,6 +55,34 @@ bug here — fix it the same way as anything else (see below): update the
54
  fixture HTML to match the new real markup, confirm the test fails against
55
  the current selectors, fix the selectors, confirm green.
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  ## The development loop (TDD, not test-after)
58
 
59
  Every change in this codebase's history followed the same loop, and new
@@ -77,6 +106,22 @@ changes should too:
77
  confirm the test actually catches it, then revert. This caught a real
78
  gap once already: a circuit breaker test that looked correct was
79
  actually vacuous until this step exposed it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  6. **Watch for shared mutable state across tests.** A few characterization
81
  tests in this codebase turned out to be recording real state into a
82
  module-level singleton (a circuit breaker) with nothing resetting it
@@ -93,8 +138,13 @@ changes should too:
93
 
94
  ## CI / deployment
95
 
96
- - **`.github/workflows/test.yml`** runs the full suite on every pull
97
- request against `main`, so a change gets feedback before merge.
 
 
 
 
 
98
  - **`.github/workflows/deploy-to-hf.yml`** runs the same suite again
99
  (`test` job) on every push to `main`, and only then (`needs: test`)
100
  pushes the repo to the Hugging Face Space (`deploy` job) with
 
26
  ## Running tests
27
 
28
  ```bash
29
+ ruff check . # same lint CI runs
30
  pytest
31
  ```
32
 
33
+ 238 tests, a few seconds, fully offline. All outbound HTTP is mocked with
34
  `respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
35
  Google Patents search) run against a real headless Chromium but navigate to
36
  local fixture HTML instead of the live sites — see `tests/helpers.py` for
 
55
  fixture HTML to match the new real markup, confirm the test fails against
56
  the current selectors, fix the selectors, confirm green.
57
 
58
+ ## How the code is laid out
59
+
60
+ - **`app.py`** is HTTP wiring only: request validation, dependency
61
+ providers, the lifespan, and mapping domain errors onto status codes.
62
+ - **`services.py`** holds the orchestration policy - which backend to try,
63
+ in what order, when to fall back, what counts as evidence a backend is
64
+ unhealthy, and when a patent is genuinely absent rather than merely
65
+ unreachable. None of that is HTTP-specific, and keeping it out of route
66
+ handlers is why most of its tests need no ASGI client.
67
+ - **`serp.py` / `scrap.py` / `ops.py`** are the backends. Each scraper is
68
+ split into a pure URL builder, a navigation step, and an `_extract_*`
69
+ function taking an already-loaded page, so all three can be tested
70
+ separately.
71
+ - **`utils.py` / `circuit_breaker.py`** are dependency-free helpers.
72
+
73
+ The services take their stateful collaborators (HTTP client, browser,
74
+ circuit breaker, OPS credentials) as constructor arguments. That is
75
+ deliberate and worth preserving: it is what lets a test build a service
76
+ with its own circuit breaker instead of resetting a process-wide singleton
77
+ between tests, and hand in a stand-in for the OPS credentials instead of
78
+ assigning to private attributes on the real token manager. `app.py`'s
79
+ `get_search_service` / `get_patent_service` providers exist so
80
+ `app.dependency_overrides` can replace them wholesale in a test.
81
+
82
+ The stateless backend functions stay module-level imports in
83
+ `services.py`; monkeypatching one of those in a test is fine, because they
84
+ hold no state to leak between tests.
85
+
86
  ## The development loop (TDD, not test-after)
87
 
88
  Every change in this codebase's history followed the same loop, and new
 
106
  confirm the test actually catches it, then revert. This caught a real
107
  gap once already: a circuit breaker test that looked correct was
108
  actually vacuous until this step exposed it.
109
+
110
+ Applying this systematically is worth a pass of its own, and one such
111
+ pass is where most of the tests added since came from. Line coverage
112
+ was 93% and hid the problem completely: the suite killed five of six
113
+ mutants aimed at pure functions, and one of seven aimed at the I/O
114
+ boundary. Deleting the entire API-key middleware left it green, as did
115
+ replacing the Bing search URL with a constant. The lesson worth keeping
116
+ is *where* to point this technique - at the code that touches the
117
+ outside world, not the code that's easy to test.
118
+
119
+ A useful heuristic fell out of it: if a property has no behavioural
120
+ signature at all, no black-box test can pin it. `==` and
121
+ `secrets.compare_digest` return the same answer for every input and
122
+ differ only in timing, so that one test asserts structurally that the
123
+ comparison goes through `compare_digest`. Reach for that only when
124
+ there is genuinely nothing observable to assert on.
125
  6. **Watch for shared mutable state across tests.** A few characterization
126
  tests in this codebase turned out to be recording real state into a
127
  module-level singleton (a circuit breaker) with nothing resetting it
 
138
 
139
  ## CI / deployment
140
 
141
+ - **`.github/workflows/test.yml`** runs `ruff check .` and then the full
142
+ suite on every pull request against `main`, so a change gets feedback
143
+ before merge. The lint rule set (see `pyproject.toml`) is deliberately
144
+ small - pyflakes-equivalent checks plus bugbear - because the first pass
145
+ over this repo found a duplicate import shadowing another, four unused
146
+ imports and an f-string with no placeholders. Widen it when the team
147
+ wants more rather than starting broad and accumulating ignores.
148
  - **`.github/workflows/deploy-to-hf.yml`** runs the same suite again
149
  (`test` job) on every push to `main`, and only then (`needs: test`)
150
  pushes the repo to the Hugging Face Space (`deploy` job) with
README.md CHANGED
@@ -19,13 +19,15 @@ pinned: true
19
  ```
20
  pip install -r requirements.txt -r requirements-dev.txt
21
  playwright install chromium # if not already present on the machine
 
22
  pytest
23
  ```
24
 
25
  The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
26
- scrapers against local fixture HTML instead of live search engines - see
27
- `tests/helpers.py` for how - so it runs fully offline and doesn't depend on,
28
- or risk tripping, any site's anti-bot measures.
 
29
 
30
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development methodology
31
  (TDD workflow, dependency pinning policy, CI/deployment) behind this project.
 
19
  ```
20
  pip install -r requirements.txt -r requirements-dev.txt
21
  playwright install chromium # if not already present on the machine
22
+ ruff check .
23
  pytest
24
  ```
25
 
26
  The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
27
+ scrapers against local fixture HTML loaded straight into a real headless
28
+ Chromium - see the `page_factory` fixture in `tests/conftest.py` - so it runs
29
+ fully offline and doesn't depend on, or risk tripping, any site's anti-bot
30
+ measures.
31
 
32
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development methodology
33
  (TDD workflow, dependency pinning policy, CI/deployment) behind this project.
app.py CHANGED
@@ -1,24 +1,24 @@
1
- import asyncio
2
  import logging
3
  import os
4
  import secrets
5
  from contextlib import asynccontextmanager
6
  from typing import Annotated, Optional
7
- from fastapi import APIRouter, FastAPI, HTTPException, Request
 
 
 
8
  from fastapi.responses import JSONResponse
9
  from fastapi.routing import APIRouter
10
- import httpx
11
- from httpx import HTTPStatusError
12
  from pydantic import BaseModel, Field, StringConstraints
13
- from playwright.async_api import async_playwright, Browser, BrowserContext, Page
14
- import uvicorn
15
 
16
- from circuit_breaker import CircuitBreaker, CircuitOpenError
17
- from scrap import PatentScrapBulkResponse, PatentScrapResult, scrap_patent_async, scrap_patent_bulk_async
18
- from serp import PATENT_ID_CORE, SerpQuery, SerpResults, query_arxiv, query_bing_search, query_brave_search, query_ddg_search, query_google_patents, query_google_scholar
19
- from ops import OPSBulkResponse, OPSNotConfigured, ops_scrap_patent, ops_scrap_patent_bulk, ops_search, token_manager as ops_token_manager
20
- from utils import log_gathered_exceptions
21
  from mcp_server import mount_mcp_server
 
 
 
 
 
22
 
23
  # Anchored version of serp.py's PATENT_ID_CORE: validates a whole
24
  # user-supplied patent id rather than finding one inside free text. Rejects
@@ -26,6 +26,11 @@ from mcp_server import mount_mcp_server
26
  # instead of it reaching Google Patents/OPS as a confusing request.
27
  PatentId = Annotated[str, StringConstraints(pattern=rf"^{PATENT_ID_CORE}$")]
28
 
 
 
 
 
 
29
  logging.basicConfig(
30
  level=logging.INFO,
31
  format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s',
@@ -36,10 +41,19 @@ logging.basicConfig(
36
  playwright = None
37
  pw_browser: Optional[Browser] = None
38
 
39
- # httpx client
 
 
40
  httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(
41
  max_connections=30, max_keepalive_connections=20))
42
 
 
 
 
 
 
 
 
43
  # ===================== Optional API key protection =====================
44
  # Unset by default, which keeps the API exactly as open as before. Set
45
  # SERPENT_API_KEY on the deployment to require every request (REST and MCP
@@ -89,23 +103,78 @@ async def api_lifespan(app: FastAPI):
89
  await pw_browser.close()
90
  if playwright:
91
  await playwright.stop()
 
92
 
93
  app = FastAPI(lifespan=api_lifespan, docs_url="/",
94
  title="SERPent", description=_load_docs())
95
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  @app.middleware("http")
98
  async def api_key_guard(request: Request, call_next):
99
  """No-op unless SERPENT_API_KEY is set; then gates every path but the docs."""
100
  if SERPENT_API_KEY and request.url.path not in _PUBLIC_PATHS:
101
  supplied = _extract_supplied_key(request)
102
- if not supplied or not secrets.compare_digest(supplied, SERPENT_API_KEY):
 
 
 
 
 
103
  return JSONResponse(
104
  {"detail": "Missing or invalid API key. Supply it via the X-API-Key or Authorization: Bearer header."},
105
  status_code=401,
106
  )
107
  return await call_next(request)
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  # Router for scrapping related endpoints
110
  scrap_router = APIRouter(prefix="/scrap", tags=["scrapping"])
111
  # Router for SERP-scrapping related endpoints
@@ -116,243 +185,105 @@ ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"])
116
  # ===================== Search endpoints =====================
117
 
118
 
119
- def _shape_serp_results(results: list) -> SerpResults:
120
- """Flatten a list of per-query result-lists (or Exceptions, from a
121
- `return_exceptions=True` gather) into one SerpResults, surfacing the
122
- last error only when every query failed.
123
- """
124
- filtered_results = [r for r in results if not isinstance(r, Exception)]
125
- flattened_results = [
126
- item for sublist in filtered_results for item in sublist]
127
-
128
- if len(filtered_results) == 0:
129
- return SerpResults(results=[], error=str(results[-1]))
130
-
131
- return SerpResults(results=flattened_results, error=None)
132
-
133
-
134
- async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
135
- """Run `fn(query, n_results)` concurrently for every query in `params`,
136
- log any failures, and shape the result the way every simple /serp and
137
- /ops search endpoint below expects.
138
- """
139
- results = await asyncio.gather(
140
- *[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
141
- log_gathered_exceptions(results, context, params)
142
- return _shape_serp_results(results)
143
-
144
-
145
  @serp_router.post("/search_scholar")
146
- async def search_google_scholar(params: SerpQuery):
147
  """Queries google scholar for the specified query"""
148
  logging.info(f"Searching Google Scholar for queries: {params.queries}")
149
- return await _run_serp_queries(
150
- lambda q, n: query_google_scholar(pw_browser, q, n), params, "google scholar search")
151
 
152
 
153
  @serp_router.post("/search_arxiv")
154
- async def search_arxiv(params: SerpQuery):
155
  """Searches arxiv for the specified queries and returns the found documents."""
156
  logging.info(f"Searching Arxiv for queries: {params.queries}")
157
- return await _run_serp_queries(
158
- lambda q, n: query_arxiv(httpx_client, q, n), params, "arxiv search")
159
 
160
 
161
  @serp_router.post("/search_patents")
162
- async def search_patents(params: SerpQuery) -> SerpResults:
163
  """Searches google patents for the specified queries and returns the found documents.
164
 
165
  Falls back to the EPO OPS API for any query Google Patents returns nothing
166
  for, when OPS credentials are configured.
167
  """
168
  logging.info(f"Searching Google Patents for queries: {params.queries}")
169
- results = await asyncio.gather(*[query_google_patents(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
170
- log_gathered_exceptions(results, "google patent search", params)
171
-
172
- # Fall back to OPS for queries that errored or returned no results.
173
- if ops_token_manager.configured:
174
- for i, res in enumerate(results):
175
- if isinstance(res, Exception) or not res:
176
- try:
177
- logging.info(
178
- f"Google Patents empty for `{params.queries[i]}`, trying OPS.")
179
- results[i] = await ops_search(httpx_client, params.queries[i], params.n_results)
180
- except Exception as e:
181
- logging.warning(f"OPS fallback failed: {e}")
182
-
183
- return _shape_serp_results(results)
184
 
185
 
186
  @serp_router.post("/search_brave")
187
- async def search_brave(params: SerpQuery) -> SerpResults:
188
  """Searches brave search for the specified queries and returns the found documents."""
189
  logging.info(f"Searching Brave Search for queries: {params.queries}")
190
- return await _run_serp_queries(
191
- lambda q, n: query_brave_search(pw_browser, q, n), params, "brave search")
192
 
193
 
194
  @serp_router.post("/search_bing")
195
- async def search_bing(params: SerpQuery) -> SerpResults:
196
  """Searches Bing search for the specified queries and returns the found documents."""
197
  logging.info(f"Searching Bing Search for queries: {params.queries}")
198
- return await _run_serp_queries(
199
- lambda q, n: query_bing_search(pw_browser, q, n), params, "bing search")
200
 
201
 
202
  @serp_router.post("/search_duck")
203
- async def search_duck(params: SerpQuery) -> SerpResults:
204
  """Searches duckduckgo for the specified queries and returns the found documents"""
205
  logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
206
- return await _run_serp_queries(
207
- lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
208
-
209
-
210
- # Shared across all queries and requests: once a backend has failed
211
- # `failure_threshold` times in a row, skip it for `cooldown_seconds` instead
212
- # of attempting (and paying the timeout cost of) another call that's very
213
- # likely to fail - and, more importantly, stop hammering a backend that may
214
- # already be rate-limiting or blocking this deployment's IP.
215
- _backend_circuit_breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0)
216
-
217
-
218
- async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
219
- """Try DDG, then Brave, then Bing for a single query; stop at the first success."""
220
- backends = [
221
- ("DuckDuckGo", lambda: query_ddg_search(q, n_results)),
222
- ("Brave Search", lambda: query_brave_search(pw_browser, q, n_results)),
223
- ("Bing", lambda: query_bing_search(pw_browser, q, n_results)),
224
- ]
225
- last_error: Optional[Exception] = None
226
- for name, call in backends:
227
- try:
228
- _backend_circuit_breaker.before_call(name)
229
- except CircuitOpenError as e:
230
- logging.info(f"Skipping {name} for query `{q}`: {e}")
231
- last_error = e
232
- continue
233
-
234
- try:
235
- logging.info(f"Querying {name} with query: `{q}`")
236
- result = await call()
237
- _backend_circuit_breaker.record_success(name)
238
- return q, result, None
239
- except Exception as e:
240
- logging.error(f"Failed to query {name} with query `{q}`: {e}")
241
- _backend_circuit_breaker.record_failure(name)
242
- last_error = e
243
-
244
- return q, [], f"All backends failed for query '{q}': {last_error}"
245
 
246
 
247
  @serp_router.post("/search")
248
- async def search(params: SerpQuery):
249
  """Attempts to search the specified queries using ALL backends"""
250
- outcomes = await asyncio.gather(*[_search_one(q, params.n_results) for q in params.queries])
251
-
252
- results: list[dict] = []
253
- errors: list[str] = []
254
- for _q, res, err in outcomes:
255
- results.extend(res)
256
- if err:
257
- errors.append(err)
258
-
259
- if len(results) == 0:
260
- return SerpResults(results=[], error="; ".join(errors) if errors else "All backends are rate-limited.")
261
-
262
- return SerpResults(results=results, error="; ".join(errors) if errors else None)
263
 
264
  # =========================== Scrapping endpoints ===========================
265
 
266
 
267
  @scrap_router.get("/scrap_patent/{patent_id}")
268
- async def scrap_patent(patent_id: PatentId) -> PatentScrapResult:
269
  """Scraps the specified patent from Google Patents.
270
 
271
  Falls back to the EPO OPS API (which covers patents missing from Google
272
  Patents) when the scrape fails and OPS credentials are configured.
273
  """
274
- try:
275
- return await scrap_patent_async(httpx_client, f"https://patents.google.com/patent/{patent_id}/en")
276
- except HTTPStatusError as e:
277
- logging.warning(
278
- f"Google Patents returned {e.response.status_code} for {patent_id}.")
279
- except Exception as e:
280
- logging.warning(f"Failed to scrap patent {patent_id}: {e}")
281
-
282
- if not ops_token_manager.configured:
283
- raise HTTPException(
284
- status_code=404,
285
- detail=f"Patent '{patent_id}' not found on Google Patents (EPO OPS fallback not configured).")
286
-
287
- try:
288
- logging.info(f"Trying OPS for patent {patent_id}.")
289
- return await ops_scrap_patent(httpx_client, patent_id)
290
- except HTTPStatusError as e:
291
- if e.response.status_code == 404:
292
- raise HTTPException(
293
- status_code=404,
294
- detail=f"Patent '{patent_id}' not found on Google Patents or EPO OPS.")
295
- raise HTTPException(
296
- status_code=502,
297
- detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
298
- except Exception as e:
299
- logging.warning(f"OPS fallback failed for {patent_id}: {e}")
300
- raise HTTPException(
301
- status_code=502,
302
- detail=f"EPO OPS request failed for '{patent_id}': {e}")
303
 
304
 
305
  class ScrapPatentsRequest(BaseModel):
306
  """Request model for scrapping multiple patents."""
307
  patent_ids: list[PatentId] = Field(...,
 
 
308
  description="List of patent IDs to scrap")
309
 
310
 
311
  @scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
312
- async def scrap_patents(params: ScrapPatentsRequest) -> PatentScrapBulkResponse:
 
313
  """Scraps multiple patents from Google Patents."""
314
- patents = await scrap_patent_bulk_async(httpx_client, params.patent_ids)
315
- return patents
316
 
317
  # =========================== EPO OPS endpoints ===========================
318
 
319
 
320
  @ops_router.post("/search")
321
- async def ops_keyword_search(params: SerpQuery) -> SerpResults:
322
  """Keyword-searches patents via the official EPO OPS API."""
323
  logging.info(f"Searching EPO OPS for queries: {params.queries}")
324
- return await _run_serp_queries(
325
- lambda q, n: ops_search(httpx_client, q, n), params, "OPS search")
326
 
327
 
328
  @ops_router.get("/scrap_patent/{patent_id}")
329
- async def ops_get_patent(patent_id: PatentId) -> PatentScrapResult:
330
  """Retrieves a patent (biblio + abstract + claims + description) via EPO OPS."""
331
- if not ops_token_manager.configured:
332
- raise HTTPException(
333
- status_code=503,
334
- detail="EPO OPS is not configured (OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET missing).")
335
- try:
336
- return await ops_scrap_patent(httpx_client, patent_id)
337
- except OPSNotConfigured:
338
- raise HTTPException(status_code=503, detail="EPO OPS is not configured.")
339
- except HTTPStatusError as e:
340
- if e.response.status_code == 404:
341
- raise HTTPException(
342
- status_code=404, detail=f"Patent '{patent_id}' not found in EPO OPS.")
343
- raise HTTPException(
344
- status_code=502,
345
- detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
346
- except Exception as e:
347
- logging.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
348
- raise HTTPException(
349
- status_code=502, detail=f"EPO OPS request failed for '{patent_id}': {e}")
350
 
351
 
352
  @ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
353
- async def ops_get_patents_bulk(params: ScrapPatentsRequest) -> OPSBulkResponse:
 
354
  """Retrieves multiple patents via EPO OPS."""
355
- return await ops_scrap_patent_bulk(httpx_client, params.patent_ids)
356
 
357
  # ===============================================================================
358
 
 
 
1
  import logging
2
  import os
3
  import secrets
4
  from contextlib import asynccontextmanager
5
  from typing import Annotated, Optional
6
+
7
+ import httpx
8
+ import uvicorn
9
+ from fastapi import Depends, FastAPI, Request
10
  from fastapi.responses import JSONResponse
11
  from fastapi.routing import APIRouter
12
+ from playwright.async_api import Browser, async_playwright
 
13
  from pydantic import BaseModel, Field, StringConstraints
 
 
14
 
15
+ from circuit_breaker import CircuitBreaker
 
 
 
 
16
  from mcp_server import mount_mcp_server
17
+ from ops import OPSBulkResponse, token_manager as ops_token_manager
18
+ from scrap import PatentScrapBulkResponse, PatentScrapResult
19
+ from serp import PATENT_ID_CORE, SerpQuery, SerpResults
20
+ from services import (OPSUnconfigured, PatentNotFound, PatentService,
21
+ SearchService, UpstreamUnavailable)
22
 
23
  # Anchored version of serp.py's PATENT_ID_CORE: validates a whole
24
  # user-supplied patent id rather than finding one inside free text. Rejects
 
26
  # instead of it reaching Google Patents/OPS as a confusing request.
27
  PatentId = Annotated[str, StringConstraints(pattern=rf"^{PATENT_ID_CORE}$")]
28
 
29
+ # Same reasoning as MAX_QUERIES_PER_REQUEST in serp.py: one accepted
30
+ # request must not be able to queue unbounded outbound work. The OPS
31
+ # variant is the expensive one - three requests per id.
32
+ MAX_BULK_PATENT_IDS = 50
33
+
34
  logging.basicConfig(
35
  level=logging.INFO,
36
  format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s',
 
41
  playwright = None
42
  pw_browser: Optional[Browser] = None
43
 
44
+ # httpx client. Constructed at import so the dependency providers below can
45
+ # close over it, but its lifetime is owned by `api_lifespan`, which closes
46
+ # it on shutdown alongside the browser.
47
  httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(
48
  max_connections=30, max_keepalive_connections=20))
49
 
50
+ # Shared across all queries and requests: once a backend has failed
51
+ # `failure_threshold` times in a row, skip it for `cooldown_seconds` instead
52
+ # of attempting (and paying the timeout cost of) another call that's very
53
+ # likely to fail - and, more importantly, stop hammering a backend that may
54
+ # already be rate-limiting or blocking this deployment's IP.
55
+ _backend_circuit_breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0)
56
+
57
  # ===================== Optional API key protection =====================
58
  # Unset by default, which keeps the API exactly as open as before. Set
59
  # SERPENT_API_KEY on the deployment to require every request (REST and MCP
 
103
  await pw_browser.close()
104
  if playwright:
105
  await playwright.stop()
106
+ await httpx_client.aclose()
107
 
108
  app = FastAPI(lifespan=api_lifespan, docs_url="/",
109
  title="SERPent", description=_load_docs())
110
 
111
 
112
+ # ============================ service dependencies ============================
113
+ # The orchestration itself lives in services.py; these just wire the app's
114
+ # collaborators into it. Overriding one of these via
115
+ # `app.dependency_overrides` is how a test supplies its own circuit breaker
116
+ # or OPS credentials, rather than reassigning module globals.
117
+
118
+
119
+ def get_search_service() -> SearchService:
120
+ return SearchService(
121
+ http_client=httpx_client,
122
+ # A provider, not the browser itself: the lifespan starts it after
123
+ # import, and leaves it None when Playwright fails to start.
124
+ browser_provider=lambda: pw_browser,
125
+ circuit_breaker=_backend_circuit_breaker,
126
+ ops_tokens=ops_token_manager,
127
+ )
128
+
129
+
130
+ def get_patent_service() -> PatentService:
131
+ return PatentService(http_client=httpx_client, ops_tokens=ops_token_manager)
132
+
133
+
134
+ SearchServiceDep = Annotated[SearchService, Depends(get_search_service)]
135
+ PatentServiceDep = Annotated[PatentService, Depends(get_patent_service)]
136
+
137
+
138
  @app.middleware("http")
139
  async def api_key_guard(request: Request, call_next):
140
  """No-op unless SERPENT_API_KEY is set; then gates every path but the docs."""
141
  if SERPENT_API_KEY and request.url.path not in _PUBLIC_PATHS:
142
  supplied = _extract_supplied_key(request)
143
+ # Compare as bytes: compare_digest raises TypeError on str arguments
144
+ # containing non-ASCII characters, and Starlette decodes headers as
145
+ # latin-1, so an accented byte in the header would otherwise turn
146
+ # this check into an unhandled 500 for any anonymous caller.
147
+ if not supplied or not secrets.compare_digest(
148
+ supplied.encode("utf-8"), SERPENT_API_KEY.encode("utf-8")):
149
  return JSONResponse(
150
  {"detail": "Missing or invalid API key. Supply it via the X-API-Key or Authorization: Bearer header."},
151
  status_code=401,
152
  )
153
  return await call_next(request)
154
 
155
+
156
+ # ========================= domain errors -> status codes =========================
157
+ # 404 is a claim that the patent does not exist, and MCP_INSTRUCTIONS tells
158
+ # agents to believe it and move on without retrying - so the services raise
159
+ # PatentNotFound only when a backend positively said so, and everything else
160
+ # arrives here as UpstreamUnavailable.
161
+
162
+
163
+ @app.exception_handler(PatentNotFound)
164
+ async def _patent_not_found_handler(request: Request, exc: PatentNotFound):
165
+ return JSONResponse({"detail": str(exc)}, status_code=404)
166
+
167
+
168
+ @app.exception_handler(UpstreamUnavailable)
169
+ async def _upstream_unavailable_handler(request: Request, exc: UpstreamUnavailable):
170
+ return JSONResponse({"detail": str(exc)}, status_code=504 if exc.timeout else 502)
171
+
172
+
173
+ @app.exception_handler(OPSUnconfigured)
174
+ async def _ops_unconfigured_handler(request: Request, exc: OPSUnconfigured):
175
+ return JSONResponse({"detail": str(exc)}, status_code=503)
176
+
177
+
178
  # Router for scrapping related endpoints
179
  scrap_router = APIRouter(prefix="/scrap", tags=["scrapping"])
180
  # Router for SERP-scrapping related endpoints
 
185
  # ===================== Search endpoints =====================
186
 
187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  @serp_router.post("/search_scholar")
189
+ async def search_google_scholar(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
190
  """Queries google scholar for the specified query"""
191
  logging.info(f"Searching Google Scholar for queries: {params.queries}")
192
+ return await service.google_scholar(params)
 
193
 
194
 
195
  @serp_router.post("/search_arxiv")
196
+ async def search_arxiv(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
197
  """Searches arxiv for the specified queries and returns the found documents."""
198
  logging.info(f"Searching Arxiv for queries: {params.queries}")
199
+ return await service.arxiv(params)
 
200
 
201
 
202
  @serp_router.post("/search_patents")
203
+ async def search_patents(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
204
  """Searches google patents for the specified queries and returns the found documents.
205
 
206
  Falls back to the EPO OPS API for any query Google Patents returns nothing
207
  for, when OPS credentials are configured.
208
  """
209
  logging.info(f"Searching Google Patents for queries: {params.queries}")
210
+ return await service.patents(params)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
 
213
  @serp_router.post("/search_brave")
214
+ async def search_brave(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
215
  """Searches brave search for the specified queries and returns the found documents."""
216
  logging.info(f"Searching Brave Search for queries: {params.queries}")
217
+ return await service.brave(params)
 
218
 
219
 
220
  @serp_router.post("/search_bing")
221
+ async def search_bing(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
222
  """Searches Bing search for the specified queries and returns the found documents."""
223
  logging.info(f"Searching Bing Search for queries: {params.queries}")
224
+ return await service.bing(params)
 
225
 
226
 
227
  @serp_router.post("/search_duck")
228
+ async def search_duck(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
229
  """Searches duckduckgo for the specified queries and returns the found documents"""
230
  logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
231
+ return await service.duckduckgo(params)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
 
234
  @serp_router.post("/search")
235
+ async def search(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
236
  """Attempts to search the specified queries using ALL backends"""
237
+ return await service.search(params)
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  # =========================== Scrapping endpoints ===========================
240
 
241
 
242
  @scrap_router.get("/scrap_patent/{patent_id}")
243
+ async def scrap_patent(patent_id: PatentId, service: PatentServiceDep) -> PatentScrapResult:
244
  """Scraps the specified patent from Google Patents.
245
 
246
  Falls back to the EPO OPS API (which covers patents missing from Google
247
  Patents) when the scrape fails and OPS credentials are configured.
248
  """
249
+ return await service.scrap(patent_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
 
252
  class ScrapPatentsRequest(BaseModel):
253
  """Request model for scrapping multiple patents."""
254
  patent_ids: list[PatentId] = Field(...,
255
+ min_length=1,
256
+ max_length=MAX_BULK_PATENT_IDS,
257
  description="List of patent IDs to scrap")
258
 
259
 
260
  @scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
261
+ async def scrap_patents(params: ScrapPatentsRequest,
262
+ service: PatentServiceDep) -> PatentScrapBulkResponse:
263
  """Scraps multiple patents from Google Patents."""
264
+ return await service.scrap_bulk(params.patent_ids)
 
265
 
266
  # =========================== EPO OPS endpoints ===========================
267
 
268
 
269
  @ops_router.post("/search")
270
+ async def ops_keyword_search(params: SerpQuery, service: SearchServiceDep) -> SerpResults:
271
  """Keyword-searches patents via the official EPO OPS API."""
272
  logging.info(f"Searching EPO OPS for queries: {params.queries}")
273
+ return await service.ops_keyword_search(params)
 
274
 
275
 
276
  @ops_router.get("/scrap_patent/{patent_id}")
277
+ async def ops_get_patent(patent_id: PatentId, service: PatentServiceDep) -> PatentScrapResult:
278
  """Retrieves a patent (biblio + abstract + claims + description) via EPO OPS."""
279
+ return await service.ops_scrap(patent_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
 
282
  @ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
283
+ async def ops_get_patents_bulk(params: ScrapPatentsRequest,
284
+ service: PatentServiceDep) -> OPSBulkResponse:
285
  """Retrieves multiple patents via EPO OPS."""
286
+ return await service.ops_scrap_bulk(params.patent_ids)
287
 
288
  # ===============================================================================
289
 
ops.py CHANGED
@@ -312,9 +312,20 @@ class OPSBulkResponse(BaseModel):
312
  failed_ids: list[str]
313
 
314
 
 
 
 
 
 
315
  async def ops_scrap_patent_bulk(client: AsyncClient, numbers: list[str]) -> OPSBulkResponse:
 
 
 
 
 
 
316
  results = await asyncio.gather(
317
- *[ops_scrap_patent(client, n) for n in numbers], return_exceptions=True)
318
  patents = [r for r in results if not isinstance(r, Exception)]
319
  failed = [numbers[i] for i, r in enumerate(
320
  results) if isinstance(r, Exception)]
 
312
  failed_ids: list[str]
313
 
314
 
315
+ # Lower than the Google Patents equivalent because each id here costs three
316
+ # OPS requests (biblio + claims + description) rather than one page fetch.
317
+ BULK_OPS_CONCURRENCY_LIMIT = 5
318
+
319
+
320
  async def ops_scrap_patent_bulk(client: AsyncClient, numbers: list[str]) -> OPSBulkResponse:
321
+ semaphore = asyncio.Semaphore(BULK_OPS_CONCURRENCY_LIMIT)
322
+
323
+ async def scrap_one(number: str):
324
+ async with semaphore:
325
+ return await ops_scrap_patent(client, number)
326
+
327
  results = await asyncio.gather(
328
+ *[scrap_one(n) for n in numbers], return_exceptions=True)
329
  patents = [r for r in results if not isinstance(r, Exception)]
330
  failed = [numbers[i] for i, r in enumerate(
331
  results) if isinstance(r, Exception)]
pyproject.toml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lint configuration. The rule set is deliberately small: this repo had no
2
+ # linter, and the first pass found a duplicate import shadowing another, four
3
+ # unused imports and an f-string with no placeholders - all of which a
4
+ # default pyflakes-equivalent catches. Widen the selection when the team
5
+ # wants more, rather than starting from a large set and adding ignores.
6
+ [tool.ruff]
7
+ line-length = 120
8
+
9
+ [tool.ruff.lint]
10
+ select = [
11
+ "F", # pyflakes: unused/duplicate imports, f-strings without placeholders
12
+ "E9", # syntax and IO errors
13
+ "B", # bugbear: likely-wrong constructs
14
+ ]
15
+ ignore = [
16
+ "B008", # FastAPI's Depends()/Field() in defaults is the documented idiom
17
+ ]
requirements-dev.txt CHANGED
@@ -10,3 +10,5 @@
10
  pytest>=8,<10
11
  pytest-asyncio>=1.4.0,<1.5
12
  respx>=0.23.1,<0.24
 
 
 
10
  pytest>=8,<10
11
  pytest-asyncio>=1.4.0,<1.5
12
  respx>=0.23.1,<0.24
13
+ # Lint. Pinned like everything else; `ruff check .` is what CI runs.
14
+ ruff>=0.14.6,<0.15
scrap.py CHANGED
@@ -1,5 +1,4 @@
1
  import asyncio
2
- import logging
3
  import re
4
  from typing import Optional
5
  from httpx import AsyncClient
@@ -131,11 +130,24 @@ class PatentScrapBulkResponse(BaseModel):
131
  failed_ids: list[str]
132
 
133
 
 
 
 
 
 
 
 
134
  async def scrap_patent_bulk_async(client: AsyncClient, patent_ids: list[str]) -> PatentScrapBulkResponse:
135
- """Scrape multiple patents asynchronously."""
136
  urls = [
137
  f"https://patents.google.com/patent/{pid}/en" for pid in patent_ids]
138
- results = await asyncio.gather(*[scrap_patent_async(client, url) for url in urls], return_exceptions=True)
 
 
 
 
 
 
139
 
140
  filtered_results = [
141
  res for res in results if not isinstance(res, Exception)]
 
1
  import asyncio
 
2
  import re
3
  from typing import Optional
4
  from httpx import AsyncClient
 
130
  failed_ids: list[str]
131
 
132
 
133
+ # Cap how many patent pages are fetched at once. Without this a single
134
+ # accepted request opened one outbound scrape per id, all at the same time;
135
+ # the Playwright scrapers have had an equivalent limit from the start and
136
+ # this brings the HTTP path in line with them.
137
+ BULK_SCRAP_CONCURRENCY_LIMIT = 10
138
+
139
+
140
  async def scrap_patent_bulk_async(client: AsyncClient, patent_ids: list[str]) -> PatentScrapBulkResponse:
141
+ """Scrape multiple patents asynchronously, a bounded number at a time."""
142
  urls = [
143
  f"https://patents.google.com/patent/{pid}/en" for pid in patent_ids]
144
+ semaphore = asyncio.Semaphore(BULK_SCRAP_CONCURRENCY_LIMIT)
145
+
146
+ async def scrap_one(url: str):
147
+ async with semaphore:
148
+ return await scrap_patent_async(client, url)
149
+
150
+ results = await asyncio.gather(*[scrap_one(url) for url in urls], return_exceptions=True)
151
 
152
  filtered_results = [
153
  res for res in results if not isinstance(res, Exception)]
serp.py CHANGED
@@ -3,11 +3,11 @@ from typing import Optional
3
  from duckduckgo_search import DDGS
4
  import httpx
5
  from pydantic import BaseModel, Field, field_validator
6
- from playwright.async_api import Browser, BrowserContext, Page, TimeoutError
7
  from urllib.parse import quote_plus
8
- import logging
9
  import re
10
  from lxml import etree
 
11
  from asyncio import Semaphore
12
 
13
  # Concurrency limit for Playwright browser contexts.
@@ -15,8 +15,18 @@ from asyncio import Semaphore
15
  PLAYWRIGHT_CONCURRENCY_LIMIT = 10
16
 
17
 
 
 
 
 
 
 
 
 
18
  class SerpQuery(BaseModel):
19
  queries: list[str] = Field(...,
 
 
20
  description="The list of queries to search for")
21
  n_results: int = Field(
22
  10, description="Number of results to return for each query. Valid values are 10, 25, 50 and 100")
@@ -50,14 +60,26 @@ class BrowserUnavailableError(Exception):
50
  super().__init__("Playwright browser is not available (it failed to start at startup).")
51
 
52
 
53
- class DuckDuckGoBlockedException(Exception):
 
 
 
 
 
 
 
 
 
 
 
 
54
  """Raised when DuckDuckGo returns zero results.
55
 
56
  The duckduckgo_search library doesn't always raise when its request is
57
  blocked/rate-limited — it can also just parse zero results from the
58
- anti-bot response page and return an empty list. Since callers (the
59
- `/serp/search` fallback chain in particular) rely on an exception to know
60
- a backend failed, treat an empty result as a failure too.
61
  """
62
 
63
  def __init__(self, *args):
@@ -78,8 +100,12 @@ async def playwright_open_page(browser: Optional[Browser]):
78
  try:
79
  yield page
80
  finally:
81
- await page.close()
82
- await context.close()
 
 
 
 
83
 
84
 
85
  async def _block_stylesheet_and_image_resources(route, request):
@@ -113,13 +139,21 @@ async def _extract_google_scholar_results(page: Page, n_results: int) -> list[di
113
  return results
114
 
115
 
 
 
 
 
 
 
 
 
 
116
  async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
117
  """Queries google scholar for the specified query and number of results. Returns relevant papers"""
118
  async with playwright_open_page(browser) as page:
119
  await page.route("**/*", _block_stylesheet_and_image_resources)
120
 
121
- url = f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
122
- await page.goto(url)
123
 
124
  return await _extract_google_scholar_results(page, n_results)
125
 
@@ -139,7 +173,7 @@ async def _extract_google_patents_results(page: Page, n_results: int) -> list[di
139
  # Wait for at least one search result item to appear
140
  # This ensures the page has loaded enough to start scraping
141
  await page.wait_for_function(
142
- f"""() => document.querySelectorAll('search-result-item').length >= 1""",
143
  timeout=30_000
144
  )
145
 
@@ -169,13 +203,17 @@ async def _extract_google_patents_results(page: Page, n_results: int) -> list[di
169
  return results[:n_results]
170
 
171
 
 
 
 
 
 
172
  async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
173
  """Queries google patents for the specified query and number of results. Returns relevant patents"""
174
  async with playwright_open_page(browser) as page:
175
  await page.route("**/*", _block_stylesheet_and_image_resources)
176
 
177
- url = f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
178
- await page.goto(url)
179
 
180
  return await _extract_google_patents_results(page, n_results)
181
 
@@ -217,13 +255,22 @@ async def _extract_brave_results(page: Page, n_results: int) -> list[dict]:
217
  return results
218
 
219
 
 
 
 
 
 
 
 
 
 
 
220
  async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
221
  """Queries Brave Search for the specified query."""
222
  async with playwright_open_page(browser) as page:
223
  await page.route("**/*", _block_stylesheet_and_image_resources)
224
 
225
- url = f"https://search.brave.com/search?q={quote_plus(q)}"
226
- await page.goto(url)
227
 
228
  return await _extract_brave_results(page, n_results)
229
 
@@ -266,25 +313,50 @@ async def _extract_bing_results(page: Page, n_results: int) -> list[dict]:
266
  return results
267
 
268
 
 
 
 
 
 
 
 
 
 
 
269
  async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
270
  """Queries bing search for the specified query"""
271
  async with playwright_open_page(browser) as page:
272
  await page.route("**/*", _block_stylesheet_and_image_resources)
273
 
274
- url = f"https://www.bing.com/search?q={quote_plus(q)}"
275
- await page.goto(url)
276
 
277
  return await _extract_bing_results(page, n_results)
278
 
279
 
 
 
 
 
 
 
 
 
 
 
280
  async def query_ddg_search(q: str, n_results: int = 10):
281
- """Queries duckduckgo search for the specified query"""
282
- ddgs = DDGS()
283
- results = []
 
 
 
 
 
284
 
285
- for result in ddgs.text(q, max_results=n_results):
286
- results.append(
287
- {"title": result["title"], "body": result["body"], "href": result["href"]})
 
288
 
289
  if not results:
290
  raise DuckDuckGoBlockedException()
 
3
  from duckduckgo_search import DDGS
4
  import httpx
5
  from pydantic import BaseModel, Field, field_validator
6
+ from playwright.async_api import Browser, BrowserContext, Page
7
  from urllib.parse import quote_plus
 
8
  import re
9
  from lxml import etree
10
+ import asyncio
11
  from asyncio import Semaphore
12
 
13
  # Concurrency limit for Playwright browser contexts.
 
15
  PLAYWRIGHT_CONCURRENCY_LIMIT = 10
16
 
17
 
18
+ # One request must not be able to fan out without limit. Every search
19
+ # endpoint issues one outbound call per query (and `/serp/search` may try
20
+ # three backends for each), so the length of this list is a direct
21
+ # multiplier on outbound load - `n_results` was clamped from the start
22
+ # while the list holding the queries was not bounded at all.
23
+ MAX_QUERIES_PER_REQUEST = 50
24
+
25
+
26
  class SerpQuery(BaseModel):
27
  queries: list[str] = Field(...,
28
+ min_length=1,
29
+ max_length=MAX_QUERIES_PER_REQUEST,
30
  description="The list of queries to search for")
31
  n_results: int = Field(
32
  10, description="Number of results to return for each query. Valid values are 10, 25, 50 and 100")
 
60
  super().__init__("Playwright browser is not available (it failed to start at startup).")
61
 
62
 
63
+ class EmptyResultsError(Exception):
64
+ """A backend returned zero results.
65
+
66
+ Deliberately distinct from a backend erroring, because zero results is
67
+ ambiguous: it can mean the query genuinely has no hits, or that the
68
+ backend served an anti-bot page that parsed to nothing. Callers (the
69
+ `/serp/search` fallback chain in particular) rely on an exception to
70
+ move on to the next backend, but should not treat this one as evidence
71
+ that the backend itself is unhealthy.
72
+ """
73
+
74
+
75
+ class DuckDuckGoBlockedException(EmptyResultsError):
76
  """Raised when DuckDuckGo returns zero results.
77
 
78
  The duckduckgo_search library doesn't always raise when its request is
79
  blocked/rate-limited — it can also just parse zero results from the
80
+ anti-bot response page and return an empty list. A hard block usually
81
+ does raise (and that still counts against the backend); this covers the
82
+ soft case, which is indistinguishable from a genuinely empty search.
83
  """
84
 
85
  def __init__(self, *args):
 
100
  try:
101
  yield page
102
  finally:
103
+ # Close the context even if closing the page fails (a crashed
104
+ # renderer), otherwise the context leaks for the process's life.
105
+ try:
106
+ await page.close()
107
+ finally:
108
+ await context.close()
109
 
110
 
111
  async def _block_stylesheet_and_image_resources(route, request):
 
139
  return results
140
 
141
 
142
+ def google_scholar_url(q: str, n_results: int) -> str:
143
+ """The Google Scholar results URL for a query.
144
+
145
+ Split out from the scraper so it can be asserted on directly: the
146
+ Playwright tests replace `page.goto` and never see the URL.
147
+ """
148
+ return f"https://scholar.google.com/scholar?q={quote_plus(q)}&num={n_results}"
149
+
150
+
151
  async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
152
  """Queries google scholar for the specified query and number of results. Returns relevant papers"""
153
  async with playwright_open_page(browser) as page:
154
  await page.route("**/*", _block_stylesheet_and_image_resources)
155
 
156
+ await page.goto(google_scholar_url(q, n_results))
 
157
 
158
  return await _extract_google_scholar_results(page, n_results)
159
 
 
173
  # Wait for at least one search result item to appear
174
  # This ensures the page has loaded enough to start scraping
175
  await page.wait_for_function(
176
+ "() => document.querySelectorAll('search-result-item').length >= 1",
177
  timeout=30_000
178
  )
179
 
 
203
  return results[:n_results]
204
 
205
 
206
+ def google_patents_search_url(q: str, n_results: int) -> str:
207
+ """The Google Patents search URL for a query."""
208
+ return f"https://patents.google.com/?q={quote_plus(q)}&num={n_results}"
209
+
210
+
211
  async def query_google_patents(browser: Browser, q: str, n_results: int = 10):
212
  """Queries google patents for the specified query and number of results. Returns relevant patents"""
213
  async with playwright_open_page(browser) as page:
214
  await page.route("**/*", _block_stylesheet_and_image_resources)
215
 
216
+ await page.goto(google_patents_search_url(q, n_results))
 
217
 
218
  return await _extract_google_patents_results(page, n_results)
219
 
 
255
  return results
256
 
257
 
258
+ def brave_search_url(q: str, n_results: int = 10) -> str:
259
+ """The Brave Search results URL for a query.
260
+
261
+ Brave paginates rather than taking a result count, so `n_results` is
262
+ applied while extracting rather than in the URL; it is accepted here to
263
+ keep the builders interchangeable.
264
+ """
265
+ return f"https://search.brave.com/search?q={quote_plus(q)}"
266
+
267
+
268
  async def query_brave_search(browser: Browser, q: str, n_results: int = 10):
269
  """Queries Brave Search for the specified query."""
270
  async with playwright_open_page(browser) as page:
271
  await page.route("**/*", _block_stylesheet_and_image_resources)
272
 
273
+ await page.goto(brave_search_url(q, n_results))
 
274
 
275
  return await _extract_brave_results(page, n_results)
276
 
 
313
  return results
314
 
315
 
316
+ def bing_search_url(q: str, n_results: int = 10) -> str:
317
+ """The Bing results URL for a query.
318
+
319
+ Bing paginates rather than taking a result count, so `n_results` is
320
+ applied while extracting rather than in the URL; it is accepted here to
321
+ keep the builders interchangeable.
322
+ """
323
+ return f"https://www.bing.com/search?q={quote_plus(q)}"
324
+
325
+
326
  async def query_bing_search(browser: Browser, q: str, n_results: int = 10):
327
  """Queries bing search for the specified query"""
328
  async with playwright_open_page(browser) as page:
329
  await page.route("**/*", _block_stylesheet_and_image_resources)
330
 
331
+ await page.goto(bing_search_url(q, n_results))
 
332
 
333
  return await _extract_bing_results(page, n_results)
334
 
335
 
336
+ def _ddg_text_blocking(q: str, n_results: int) -> list[dict]:
337
+ """The synchronous half of a DuckDuckGo query.
338
+
339
+ `DDGS.text()` performs blocking HTTP I/O. It is deliberately isolated
340
+ here so `query_ddg_search` can hand it to a worker thread rather than
341
+ calling it on the event loop.
342
+ """
343
+ return list(DDGS().text(q, max_results=n_results))
344
+
345
+
346
  async def query_ddg_search(q: str, n_results: int = 10):
347
+ """Queries duckduckgo search for the specified query.
348
+
349
+ The underlying library call is synchronous, so it runs in a worker
350
+ thread: calling it directly would freeze the whole event loop for the
351
+ duration of the request, and DuckDuckGo is the first backend tried for
352
+ every query in `/serp/search`.
353
+ """
354
+ raw_results = await asyncio.to_thread(_ddg_text_blocking, q, n_results)
355
 
356
+ results = [
357
+ {"title": r["title"], "body": r["body"], "href": r["href"]}
358
+ for r in raw_results
359
+ ]
360
 
361
  if not results:
362
  raise DuckDuckGoBlockedException()
services.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend orchestration, separated from HTTP wiring.
2
+
3
+ The policy in here - which backend to try, in what order, when to fall back
4
+ to another, what counts as evidence a backend is unhealthy, and when a
5
+ patent is genuinely absent rather than merely unreachable - is domain
6
+ policy. It would be equally true behind a CLI or a queue consumer, and it
7
+ was previously written inline in FastAPI route handlers, which meant
8
+ exercising any of it cost an ASGI round-trip.
9
+
10
+ The stateful collaborators (HTTP client, browser, circuit breaker, OPS
11
+ credentials) are constructor arguments rather than module globals reached
12
+ into at call time. That is what lets a test build a service with its own
13
+ circuit breaker instead of resetting a process-wide singleton between
14
+ tests, and hand in a stand-in for the OPS credentials instead of assigning
15
+ to private attributes on the real token manager.
16
+
17
+ The stateless backend functions (`query_arxiv` and friends) stay
18
+ module-level imports: they hold no state, so a test that wants to replace
19
+ one can monkeypatch it here without any of the problems shared mutable
20
+ state causes.
21
+
22
+ Services raise domain errors - `PatentNotFound`, `UpstreamUnavailable` -
23
+ rather than `HTTPException`. Mapping those onto status codes is the HTTP
24
+ layer's job, and lives in app.py.
25
+ """
26
+
27
+ import asyncio
28
+ import logging
29
+ from typing import Callable, Optional
30
+
31
+ import httpx
32
+ from httpx import AsyncClient, HTTPStatusError
33
+ from playwright.async_api import Browser
34
+
35
+ from circuit_breaker import CircuitBreaker, CircuitOpenError
36
+ from ops import (OPSBulkResponse, ops_scrap_patent, ops_scrap_patent_bulk,
37
+ ops_search)
38
+ from scrap import (PatentScrapBulkResponse, PatentScrapResult,
39
+ scrap_patent_async, scrap_patent_bulk_async)
40
+ from serp import (EmptyResultsError, SerpQuery, SerpResults, query_arxiv,
41
+ query_bing_search, query_brave_search, query_ddg_search,
42
+ query_google_patents, query_google_scholar)
43
+ from utils import log_gathered_exceptions
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ # ================================ domain errors ================================
49
+
50
+
51
+ class PatentNotFound(Exception):
52
+ """Every backend consulted positively said the document is absent.
53
+
54
+ Distinct from UpstreamUnavailable on purpose: MCP_INSTRUCTIONS tells an
55
+ agent that a not-found patent is genuinely absent everywhere and to
56
+ move on without retrying, so this must never stand in for "we couldn't
57
+ reach anyone".
58
+ """
59
+
60
+
61
+ class UpstreamUnavailable(Exception):
62
+ """A backend failed to answer. Says nothing about whether the document
63
+ exists."""
64
+
65
+ def __init__(self, message: str, *, timeout: bool = False):
66
+ super().__init__(message)
67
+ self.timeout = timeout
68
+
69
+
70
+ class OPSUnconfigured(Exception):
71
+ """An OPS-only operation was requested without credentials configured."""
72
+
73
+
74
+ def _is_not_found(exc: BaseException) -> bool:
75
+ """True only when a backend positively said the document is absent."""
76
+ return isinstance(exc, HTTPStatusError) and exc.response.status_code == 404
77
+
78
+
79
+ def _as_upstream(exc: BaseException, message: str) -> UpstreamUnavailable:
80
+ return UpstreamUnavailable(message, timeout=isinstance(exc, httpx.TimeoutException))
81
+
82
+
83
+ # ================================ result shaping ================================
84
+
85
+
86
+ def shape_serp_results(results: list) -> SerpResults:
87
+ """Flatten a list of per-query result-lists (or Exceptions, from a
88
+ `return_exceptions=True` gather) into one SerpResults, surfacing the
89
+ last error only when every query failed.
90
+ """
91
+ if not results:
92
+ # The request models bound the query list, so this is unreachable
93
+ # from the endpoints; keep the helper total anyway rather than
94
+ # indexing into an empty list, since every search path shares it.
95
+ return SerpResults(results=[], error="No queries were provided.")
96
+
97
+ filtered_results = [r for r in results if not isinstance(r, Exception)]
98
+ flattened_results = [
99
+ item for sublist in filtered_results for item in sublist]
100
+
101
+ if len(filtered_results) == 0:
102
+ return SerpResults(results=[], error=str(results[-1]))
103
+
104
+ return SerpResults(results=flattened_results, error=None)
105
+
106
+
107
+ # ================================= search service =================================
108
+
109
+
110
+ class SearchService:
111
+ """Runs SERP queries across the available backends."""
112
+
113
+ def __init__(self, *, http_client: AsyncClient,
114
+ browser_provider: Callable[[], Optional[Browser]],
115
+ circuit_breaker: CircuitBreaker, ops_tokens):
116
+ self._http = http_client
117
+ # A provider rather than the browser itself: the browser is started
118
+ # by the app's lifespan, after this service may already exist, and
119
+ # is None when Playwright failed to start.
120
+ self._browser_provider = browser_provider
121
+ self._breaker = circuit_breaker
122
+ self._ops_tokens = ops_tokens
123
+
124
+ @property
125
+ def browser(self) -> Optional[Browser]:
126
+ return self._browser_provider()
127
+
128
+ async def _run_queries(self, fn, params: SerpQuery, context: str) -> SerpResults:
129
+ """Run `fn(query, n_results)` concurrently for every query, log any
130
+ failures, and shape the result the way every simple search path
131
+ expects.
132
+ """
133
+ results = await asyncio.gather(
134
+ *[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
135
+ log_gathered_exceptions(results, context, params.queries)
136
+ return shape_serp_results(results)
137
+
138
+ # ------------------------------ single backend ------------------------------
139
+
140
+ async def google_scholar(self, params: SerpQuery) -> SerpResults:
141
+ return await self._run_queries(
142
+ lambda q, n: query_google_scholar(self.browser, q, n), params,
143
+ "google scholar search")
144
+
145
+ async def arxiv(self, params: SerpQuery) -> SerpResults:
146
+ return await self._run_queries(
147
+ lambda q, n: query_arxiv(self._http, q, n), params, "arxiv search")
148
+
149
+ async def brave(self, params: SerpQuery) -> SerpResults:
150
+ return await self._run_queries(
151
+ lambda q, n: query_brave_search(self.browser, q, n), params, "brave search")
152
+
153
+ async def bing(self, params: SerpQuery) -> SerpResults:
154
+ return await self._run_queries(
155
+ lambda q, n: query_bing_search(self.browser, q, n), params, "bing search")
156
+
157
+ async def duckduckgo(self, params: SerpQuery) -> SerpResults:
158
+ return await self._run_queries(
159
+ lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
160
+
161
+ async def ops_keyword_search(self, params: SerpQuery) -> SerpResults:
162
+ return await self._run_queries(
163
+ lambda q, n: ops_search(self._http, q, n), params, "OPS search")
164
+
165
+ # -------------------------------- patents --------------------------------
166
+
167
+ async def patents(self, params: SerpQuery) -> SerpResults:
168
+ """Google Patents, falling back to EPO OPS for any query it returns
169
+ nothing for (when OPS credentials are configured)."""
170
+ results = await asyncio.gather(
171
+ *[query_google_patents(self.browser, q, params.n_results)
172
+ for q in params.queries],
173
+ return_exceptions=True)
174
+ log_gathered_exceptions(results, "google patent search", params.queries)
175
+
176
+ # Gathered rather than awaited one at a time: this path exists for
177
+ # when Google Patents is unavailable, so it is exactly when a
178
+ # request can least afford N sequential round-trips to the slower
179
+ # backend.
180
+ if self._ops_tokens.configured:
181
+ needs_fallback = [
182
+ i for i, res in enumerate(results)
183
+ if isinstance(res, Exception) or not res]
184
+ if needs_fallback:
185
+ logger.info(
186
+ f"Google Patents empty for {len(needs_fallback)} quer(y/ies), trying OPS.")
187
+ fallback = await asyncio.gather(
188
+ *[ops_search(self._http, params.queries[i], params.n_results)
189
+ for i in needs_fallback],
190
+ return_exceptions=True)
191
+ # strict: gather returns exactly one result per index, so a
192
+ # length mismatch here would be a bug worth surfacing.
193
+ for i, res in zip(needs_fallback, fallback, strict=True):
194
+ if isinstance(res, Exception):
195
+ logger.warning(f"OPS fallback failed for `{params.queries[i]}`: {res}")
196
+ else:
197
+ results[i] = res
198
+
199
+ return shape_serp_results(results)
200
+
201
+ # ----------------------------- all backends -----------------------------
202
+
203
+ async def _search_one(self, q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
204
+ """Try DDG, then Brave, then Bing for a single query; stop at the
205
+ first success."""
206
+ backends = [
207
+ ("DuckDuckGo", lambda: query_ddg_search(q, n_results)),
208
+ ("Brave Search", lambda: query_brave_search(self.browser, q, n_results)),
209
+ ("Bing", lambda: query_bing_search(self.browser, q, n_results)),
210
+ ]
211
+ last_error: Optional[Exception] = None
212
+ for name, call in backends:
213
+ try:
214
+ self._breaker.before_call(name)
215
+ except CircuitOpenError as e:
216
+ logger.info(f"Skipping {name} for query `{q}`: {e}")
217
+ last_error = e
218
+ continue
219
+
220
+ try:
221
+ logger.info(f"Querying {name} with query: `{q}`")
222
+ result = await call()
223
+ self._breaker.record_success(name)
224
+ return q, result, None
225
+ except EmptyResultsError as e:
226
+ # Zero results is ambiguous - no hits, or a soft block that
227
+ # parsed to nothing. Move on to the next backend, but don't
228
+ # hold it against this one: the breaker is shared across
229
+ # every request, so counting it would let a few
230
+ # unusual-but-legitimate queries disable the primary backend
231
+ # for everyone. A hard block normally raises a real error,
232
+ # which is handled below.
233
+ logger.info(f"{name} returned no results for query `{q}`")
234
+ last_error = e
235
+ except Exception as e:
236
+ logger.error(f"Failed to query {name} with query `{q}`: {e}")
237
+ self._breaker.record_failure(name)
238
+ last_error = e
239
+
240
+ return q, [], f"All backends failed for query '{q}': {last_error}"
241
+
242
+ async def search(self, params: SerpQuery) -> SerpResults:
243
+ """Search every query across the full backend fallback chain."""
244
+ outcomes = await asyncio.gather(
245
+ *[self._search_one(q, params.n_results) for q in params.queries])
246
+
247
+ results: list[dict] = []
248
+ errors: list[str] = []
249
+ for _q, res, err in outcomes:
250
+ results.extend(res)
251
+ if err:
252
+ errors.append(err)
253
+
254
+ if len(results) == 0:
255
+ return SerpResults(
256
+ results=[],
257
+ error="; ".join(errors) if errors else "All backends are rate-limited.")
258
+
259
+ return SerpResults(results=results, error="; ".join(errors) if errors else None)
260
+
261
+
262
+ # ================================= patent service =================================
263
+
264
+
265
+ class PatentService:
266
+ """Retrieves patent full text, from Google Patents or EPO OPS."""
267
+
268
+ def __init__(self, *, http_client: AsyncClient, ops_tokens):
269
+ self._http = http_client
270
+ self._ops_tokens = ops_tokens
271
+
272
+ async def scrap(self, patent_id: str) -> PatentScrapResult:
273
+ """Scrape from Google Patents, falling back to EPO OPS.
274
+
275
+ Raises PatentNotFound only when every backend consulted said the
276
+ document is absent; anything else raises UpstreamUnavailable.
277
+ """
278
+ google_error: BaseException
279
+ try:
280
+ return await scrap_patent_async(
281
+ self._http, f"https://patents.google.com/patent/{patent_id}/en")
282
+ except HTTPStatusError as e:
283
+ google_error = e
284
+ logger.warning(
285
+ f"Google Patents returned {e.response.status_code} for {patent_id}.")
286
+ except Exception as e:
287
+ google_error = e
288
+ logger.warning(f"Failed to scrap patent {patent_id}: {e}")
289
+
290
+ if not self._ops_tokens.configured:
291
+ if _is_not_found(google_error):
292
+ raise PatentNotFound(
293
+ f"Patent '{patent_id}' not found on Google Patents "
294
+ "(EPO OPS fallback not configured).")
295
+ raise _as_upstream(
296
+ google_error,
297
+ f"Google Patents request failed for '{patent_id}' and the EPO OPS "
298
+ f"fallback is not configured: {google_error}")
299
+
300
+ try:
301
+ logger.info(f"Trying OPS for patent {patent_id}.")
302
+ return await ops_scrap_patent(self._http, patent_id)
303
+ except Exception as e:
304
+ logger.warning(f"OPS fallback failed for {patent_id}: {e}")
305
+ # Only claim the patent doesn't exist when *both* backends said so.
306
+ if _is_not_found(google_error) and _is_not_found(e):
307
+ raise PatentNotFound(
308
+ f"Patent '{patent_id}' not found on Google Patents or EPO OPS.") from e
309
+ if isinstance(e, HTTPStatusError):
310
+ raise _as_upstream(
311
+ e, f"EPO OPS returned {e.response.status_code} for '{patent_id}'.") from e
312
+ raise _as_upstream(e, f"EPO OPS request failed for '{patent_id}': {e}") from e
313
+
314
+ async def scrap_bulk(self, patent_ids: list[str]) -> PatentScrapBulkResponse:
315
+ return await scrap_patent_bulk_async(self._http, patent_ids)
316
+
317
+ async def ops_scrap(self, patent_id: str) -> PatentScrapResult:
318
+ """Retrieve via EPO OPS only, with no Google Patents fallback."""
319
+ if not self._ops_tokens.configured:
320
+ raise OPSUnconfigured(
321
+ "EPO OPS is not configured (OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET missing).")
322
+ try:
323
+ return await ops_scrap_patent(self._http, patent_id)
324
+ except HTTPStatusError as e:
325
+ if e.response.status_code == 404:
326
+ # OPS is the only backend asked here, so its 404 is the
327
+ # whole answer.
328
+ raise PatentNotFound(
329
+ f"Patent '{patent_id}' not found in EPO OPS.") from e
330
+ raise _as_upstream(
331
+ e, f"EPO OPS returned {e.response.status_code} for '{patent_id}'.") from e
332
+ except Exception as e:
333
+ logger.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
334
+ raise _as_upstream(e, f"EPO OPS request failed for '{patent_id}': {e}") from e
335
+
336
+ async def ops_scrap_bulk(self, patent_ids: list[str]) -> OPSBulkResponse:
337
+ return await ops_scrap_patent_bulk(self._http, patent_ids)
tests/conftest.py CHANGED
@@ -59,3 +59,29 @@ async def browser():
59
  yield b
60
  await b.close()
61
  await pw.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  yield b
60
  await b.close()
61
  await pw.stop()
62
+
63
+
64
+ @pytest_asyncio.fixture
65
+ async def page_factory(browser):
66
+ """Returns a callable producing a real Page with fixture HTML loaded.
67
+
68
+ This is the whole seam the Playwright-driven tests need now that every
69
+ scraper is split into a URL builder, a navigation step and an
70
+ `_extract_*` function: the extractors take an already-loaded page, so
71
+ `set_content` reaches them directly. It replaces the goto-patching
72
+ Browser wrapper that used to be required, which had to accept the
73
+ navigation URL and throw it away - and so hid every URL bug.
74
+ """
75
+ contexts = []
76
+
77
+ async def make(html: str):
78
+ context = await browser.new_context()
79
+ contexts.append(context)
80
+ page = await context.new_page()
81
+ await page.set_content(html)
82
+ return page
83
+
84
+ yield make
85
+
86
+ for context in contexts:
87
+ await context.close()
tests/helpers.py CHANGED
@@ -1,7 +1,4 @@
1
- """Shared test helpers: fixture loading and a fake-navigation Playwright
2
- Browser wrapper used to characterize the scraper functions in `serp.py`
3
- without hitting live search engines.
4
- """
5
 
6
  from pathlib import Path
7
 
@@ -11,63 +8,3 @@ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
11
  def load_fixture(*parts: str) -> str:
12
  """Read a text fixture from tests/fixtures/<parts...>."""
13
  return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
14
-
15
-
16
- class _GotoPatchingContext:
17
- """Wraps a real Playwright BrowserContext so every Page it creates loads
18
- fixture HTML via `set_content()` instead of navigating over the network.
19
- """
20
-
21
- def __init__(self, real_context, html: str):
22
- self._real_context = real_context
23
- self._html = html
24
-
25
- async def new_page(self):
26
- page = await self._real_context.new_page()
27
- html = self._html
28
-
29
- async def fake_goto(url, **kwargs):
30
- # The scraper functions don't use the response object `goto`
31
- # normally returns, so a plain None is a safe stand-in.
32
- await page.set_content(html)
33
- return None
34
-
35
- page.goto = fake_goto
36
- return page
37
-
38
- async def close(self):
39
- await self._real_context.close()
40
-
41
-
42
- class _GotoPatchingBrowser:
43
- """Wraps a real Playwright Browser the same way, so it can be passed
44
- directly to `serp.py` functions in place of the real `pw_browser`.
45
- """
46
-
47
- def __init__(self, real_browser, html: str):
48
- self._real_browser = real_browser
49
- self._html = html
50
-
51
- async def new_context(self, **kwargs):
52
- real_context = await self._real_browser.new_context(**kwargs)
53
- return _GotoPatchingContext(real_context, self._html)
54
-
55
-
56
- def make_fixture_browser(real_browser, html: str) -> _GotoPatchingBrowser:
57
- """Build a Browser-like object for characterizing `serp.py`'s scraper
58
- functions against fixture HTML.
59
-
60
- Why this exists: each scraper (e.g. `query_bing_search`) registers its
61
- own catch-all `page.route("**/*", ...)` resource blocker *inside* the
62
- function, and that handler calls `route.continue_()` (not
63
- `route.fallback()`) for the main document request. Playwright invokes
64
- the most-recently-registered matching route first, so any route we
65
- register on the page *before* calling the function never runs -
66
- verified empirically, it's not a documented guarantee either way.
67
- Patching `page.goto` itself, on a page created through this wrapper, is
68
- the only reliable seam into these functions without touching their
69
- source (see the code review this test suite followed from: the
70
- fetch/navigate and parse logic aren't separated, which is exactly what
71
- makes this workaround necessary).
72
- """
73
- return _GotoPatchingBrowser(real_browser, html)
 
1
+ """Shared test helpers: fixture loading."""
 
 
 
2
 
3
  from pathlib import Path
4
 
 
8
  def load_fixture(*parts: str) -> str:
9
  """Read a text fixture from tests/fixtures/<parts...>."""
10
  return FIXTURES_DIR.joinpath(*parts).read_text(encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_app.py CHANGED
@@ -1,12 +1,10 @@
1
- """Characterization tests for app.py's search endpoints.
2
-
3
- These monkeypatch the underlying query_*/ops_search functions rather than
4
- hitting real search engines - app.py's own job here is just wiring
5
- (gather queries concurrently, log failures, flatten results, decide when to
6
- report an error), which is exactly what's being tested. The app's lifespan
7
- (which starts a real Playwright browser) is never triggered: ASGITransport
8
- doesn't run it, and these tests don't need it since pw_browser is never
9
- actually used once the query functions are patched out.
10
  """
11
 
12
  import httpx
@@ -14,9 +12,8 @@ import pytest
14
  from httpx import ASGITransport
15
 
16
  import app as app_module
17
- from circuit_breaker import CircuitBreaker
18
- from scrap import PatentScrapResult
19
- from serp import SerpQuery
20
 
21
 
22
  @pytest.fixture
@@ -26,151 +23,134 @@ async def client():
26
  yield c
27
 
28
 
29
- @pytest.fixture(autouse=True)
30
- def _fresh_backend_circuit_breaker(monkeypatch):
31
- """`_backend_circuit_breaker` is a module-level singleton shared across
32
- every request in production, which is the point - but it means a
33
- backend failure recorded by one test would otherwise leak into the
34
- next. Give every test its own instance.
35
- """
36
- monkeypatch.setattr(app_module, "_backend_circuit_breaker", CircuitBreaker())
37
-
38
-
39
- # ------------------------- _shape_serp_results / _run_serp_queries -------------------------
40
 
 
 
41
 
42
- def test_shape_serp_results_flattens_successful_lists():
43
- result = app_module._shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
44
- assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
45
- assert result.error is None
46
 
 
 
47
 
48
- def test_shape_serp_results_surfaces_last_error_when_all_failed():
49
- result = app_module._shape_serp_results([ValueError("first"), RuntimeError("second")])
50
- assert result.results == []
51
- assert "second" in result.error
52
 
 
 
 
 
 
53
 
54
- def test_shape_serp_results_ignores_failures_when_some_succeed():
55
- result = app_module._shape_serp_results([[{"a": 1}], RuntimeError("boom")])
56
- assert result.results == [{"a": 1}]
57
- assert result.error is None
58
 
59
 
60
- async def test_run_serp_queries_calls_fn_per_query_and_shapes_result():
61
- params = SerpQuery(queries=["x", "y"], n_results=5)
62
- calls = []
63
 
64
- async def fn(q, n):
65
- calls.append((q, n))
66
- return [{"q": q}]
67
 
68
- result = await app_module._run_serp_queries(fn, params, "test context")
 
 
69
 
70
- assert sorted(calls) == [("x", 5), ("y", 5)]
71
- assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
 
72
 
 
 
 
73
 
74
- # ------------------------------------- endpoints -------------------------------------
75
 
 
76
 
77
- async def test_search_arxiv_flattens_results_from_all_queries(client, monkeypatch):
78
- async def fake_query_arxiv(client_arg, q, n):
79
- return [{"title": f"paper about {q}", "href": "x", "body": "y", "id": "1"}]
80
 
81
- monkeypatch.setattr(app_module, "query_arxiv", fake_query_arxiv)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
 
83
- resp = await client.post("/serp/search_arxiv", json={"queries": ["a", "b"], "n_results": 5})
84
 
85
  assert resp.status_code == 200
86
- data = resp.json()
87
- assert data["error"] is None
88
- assert {r["title"] for r in data["results"]} == {"paper about a", "paper about b"}
89
 
90
 
91
- async def test_search_arxiv_all_queries_failing_returns_error(client, monkeypatch):
92
- async def failing_query_arxiv(client_arg, q, n):
93
- raise RuntimeError(f"boom for {q}")
 
 
 
 
 
94
 
95
- monkeypatch.setattr(app_module, "query_arxiv", failing_query_arxiv)
96
-
97
- resp = await client.post("/serp/search_arxiv", json={"queries": ["a"], "n_results": 5})
98
 
99
  assert resp.status_code == 200
100
- data = resp.json()
101
- assert data["results"] == []
102
- assert "boom for a" in data["error"]
103
-
104
-
105
- async def test_search_patents_does_not_fall_back_when_google_patents_succeeds(client, monkeypatch):
106
- async def fake_google_patents(browser, q, n):
107
- return [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
108
-
109
- ops_search_called = False
110
-
111
- async def fake_ops_search(client_arg, q, n):
112
- nonlocal ops_search_called
113
- ops_search_called = True
114
- return []
115
-
116
- monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
117
- monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
118
- monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
119
- monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
120
-
121
- resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
122
-
123
- data = resp.json()
124
- assert data["results"] == [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
125
- assert ops_search_called is False
126
-
127
-
128
- async def test_search_patents_falls_back_to_ops_when_google_patents_empty(client, monkeypatch):
129
- async def fake_google_patents(browser, q, n):
130
- return []
131
 
132
- async def fake_ops_search(client_arg, q, n):
133
- return [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
134
 
135
- monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
136
- monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
137
- monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
138
- monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
 
 
 
 
139
 
140
- resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
141
 
142
- data = resp.json()
143
- assert data["results"] == [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
144
-
145
-
146
- async def test_search_patents_no_fallback_when_ops_not_configured(client, monkeypatch):
147
- async def fake_google_patents(browser, q, n):
148
- return []
149
-
150
- monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
151
- monkeypatch.setattr(app_module.ops_token_manager, "_key", None)
152
- monkeypatch.setattr(app_module.ops_token_manager, "_secret", None)
153
-
154
- resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
155
-
156
- data = resp.json()
157
- assert data["results"] == []
158
- assert data["error"] is None
159
 
160
 
161
  # ------------------------------- patent_id validation -------------------------------
162
 
163
 
164
- async def test_scrap_patent_rejects_malformed_patent_id(client):
165
- resp = await client.get("/scrap/scrap_patent/not-a-patent-id")
 
 
 
 
166
  assert resp.status_code == 422
167
 
168
 
169
- async def test_scrap_patent_accepts_well_formed_patent_id(client, monkeypatch):
170
- async def fake_scrap(client_arg, url):
171
- return PatentScrapResult(title="Widget apparatus")
 
 
172
 
173
- monkeypatch.setattr(app_module, "scrap_patent_async", fake_scrap)
 
 
174
 
175
  resp = await client.get("/scrap/scrap_patent/US11930446B2")
176
 
@@ -178,38 +158,47 @@ async def test_scrap_patent_accepts_well_formed_patent_id(client, monkeypatch):
178
  assert resp.json()["title"] == "Widget apparatus"
179
 
180
 
181
- async def test_ops_get_patent_rejects_malformed_patent_id(client):
182
- resp = await client.get("/ops/scrap_patent/not-a-patent-id")
183
- assert resp.status_code == 422
184
 
185
 
186
- async def test_scrap_patents_bulk_rejects_malformed_id_in_list(client):
187
- resp = await client.post(
188
- "/scrap/scrap_patents_bulk", json={"patent_ids": ["US11930446B2", "garbage!!"]})
189
- assert resp.status_code == 422
190
 
 
 
 
191
 
192
- async def test_ops_scrap_patents_bulk_rejects_malformed_id_in_list(client):
193
- resp = await client.post("/ops/scrap_patents_bulk", json={"patent_ids": ["garbage!!"]})
194
- assert resp.status_code == 422
195
 
 
 
 
 
 
 
196
 
197
- async def test_ops_keyword_search_flattens_results(client, monkeypatch):
198
- async def fake_ops_search(client_arg, q, n):
199
- return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
200
 
201
- monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
202
 
203
- resp = await client.post("/ops/search", json={"queries": ["a", "b"], "n_results": 5})
 
204
 
205
- data = resp.json()
206
- assert data["error"] is None
207
- assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
208
 
209
 
210
  # ------------------------------------- api_lifespan -------------------------------------
211
 
212
 
 
 
 
 
 
 
 
 
213
  async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
214
  """The container runs as a non-root user (see the Dockerfile), and
215
  Chromium's own internal sandbox needs privileges a non-root container
@@ -243,6 +232,10 @@ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
243
  monkeypatch.setattr(app_module, "pw_browser", None)
244
  monkeypatch.setattr(app_module, "playwright", None)
245
  monkeypatch.setattr(app_module, "async_playwright", lambda: FakePlaywrightContextManager())
 
 
 
 
246
 
247
  async with app_module.api_lifespan(app_module.app):
248
  pass
@@ -251,94 +244,35 @@ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
251
  assert "--no-sandbox" in launch_calls[0].get("args", [])
252
 
253
 
254
- # --------------------------------- _search_one fallback ---------------------------------
255
-
256
-
257
- async def test_search_one_returns_first_backend_that_succeeds(monkeypatch):
258
- calls = []
259
-
260
- async def fake_ddg(q, n):
261
- calls.append("ddg")
262
- return [{"title": "from ddg"}]
263
-
264
- async def fake_brave(browser, q, n):
265
- calls.append("brave")
266
- return [{"title": "from brave"}]
267
-
268
- monkeypatch.setattr(app_module, "query_ddg_search", fake_ddg)
269
- monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
270
-
271
- q, results, err = await app_module._search_one("widget", 5)
272
-
273
- assert calls == ["ddg"]
274
- assert results == [{"title": "from ddg"}]
275
- assert err is None
276
-
277
-
278
- async def test_search_one_falls_through_to_the_next_backend_on_failure(monkeypatch):
279
- calls = []
280
-
281
- async def failing_ddg(q, n):
282
- calls.append("ddg")
283
- raise RuntimeError("ddg blocked")
284
-
285
- async def fake_brave(browser, q, n):
286
- calls.append("brave")
287
- return [{"title": "from brave"}]
288
-
289
- monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
290
- monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
291
-
292
- q, results, err = await app_module._search_one("widget", 5)
293
-
294
- assert calls == ["ddg", "brave"]
295
- assert results == [{"title": "from brave"}]
296
- assert err is None
297
-
298
-
299
- async def test_search_one_reports_an_error_when_every_backend_fails(monkeypatch):
300
- async def failing(*args):
301
- raise RuntimeError("blocked")
302
-
303
- monkeypatch.setattr(app_module, "query_ddg_search", failing)
304
- monkeypatch.setattr(app_module, "query_brave_search", failing)
305
- monkeypatch.setattr(app_module, "query_bing_search", failing)
306
-
307
- q, results, err = await app_module._search_one("widget", 5)
308
-
309
- assert results == []
310
- assert "All backends failed for query 'widget'" in err
311
- assert "blocked" in err
312
-
313
-
314
- async def test_search_one_skips_a_backend_whose_circuit_is_open(monkeypatch):
315
- """A backend that's failed `failure_threshold` times in a row shouldn't
316
- be attempted again until its cooldown elapses - repeatedly hammering a
317
- backend that's already rate-limiting us only makes that worse.
318
  """
319
- from circuit_breaker import CircuitBreaker
320
-
321
- fresh_breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60)
322
- monkeypatch.setattr(app_module, "_backend_circuit_breaker", fresh_breaker)
323
-
324
- ddg_calls = []
 
 
325
 
326
- async def failing_ddg(q, n):
327
- ddg_calls.append(q)
328
- raise RuntimeError("ddg blocked")
329
 
330
- async def fake_brave(browser, q, n):
331
- return [{"title": "from brave"}]
332
 
333
- monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
334
- monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
335
 
336
- # First call: DDG fails, opening its circuit (threshold=1); Brave picks it up.
337
- await app_module._search_one("first query", 5)
338
- assert ddg_calls == ["first query"]
 
 
 
 
 
 
 
339
 
340
- # Second call: DDG's circuit is now open, so it should be skipped
341
- # entirely (not called again) and go straight to Brave.
342
- q, results, err = await app_module._search_one("second query", 5)
343
- assert ddg_calls == ["first query"] # unchanged - DDG was skipped
344
- assert results == [{"title": "from brave"}]
 
1
+ """app.py: HTTP wiring only.
2
+
3
+ The orchestration these endpoints used to contain now lives in
4
+ services.py and is tested in test_search_service.py and
5
+ test_patent_fallback.py without an HTTP layer. What's left here is what
6
+ app.py is actually responsible for: request validation, dependency wiring,
7
+ the lifespan, and turning domain results into responses.
 
 
8
  """
9
 
10
  import httpx
 
12
  from httpx import ASGITransport
13
 
14
  import app as app_module
15
+ from scrap import PatentScrapBulkResponse, PatentScrapResult
16
+ from serp import SerpResults
 
17
 
18
 
19
  @pytest.fixture
 
23
  yield c
24
 
25
 
26
+ @pytest.fixture
27
+ def override_services():
28
+ """Install stand-in services for the duration of one test."""
29
+ def _install(*, search=None, patent=None):
30
+ if search is not None:
31
+ app_module.app.dependency_overrides[app_module.get_search_service] = lambda: search
32
+ if patent is not None:
33
+ app_module.app.dependency_overrides[app_module.get_patent_service] = lambda: patent
 
 
 
34
 
35
+ yield _install
36
+ app_module.app.dependency_overrides.clear()
37
 
 
 
 
 
38
 
39
+ class _RecordingSearchService:
40
+ """Records which service method each endpoint dispatched to."""
41
 
42
+ def __init__(self):
43
+ self.calls = []
 
 
44
 
45
+ def _record(self, name):
46
+ async def _fn(params):
47
+ self.calls.append((name, list(params.queries), params.n_results))
48
+ return SerpResults(results=[{"title": name}], error=None)
49
+ return _fn
50
 
51
+ def __getattr__(self, name):
52
+ return self._record(name)
 
 
53
 
54
 
55
+ class _StubPatentService:
56
+ def __init__(self):
57
+ self.calls = []
58
 
59
+ async def scrap(self, patent_id):
60
+ self.calls.append(("scrap", patent_id))
61
+ return PatentScrapResult(title="Widget apparatus")
62
 
63
+ async def scrap_bulk(self, patent_ids):
64
+ self.calls.append(("scrap_bulk", patent_ids))
65
+ return PatentScrapBulkResponse(patents=[], failed_ids=list(patent_ids))
66
 
67
+ async def ops_scrap(self, patent_id):
68
+ self.calls.append(("ops_scrap", patent_id))
69
+ return PatentScrapResult(title="From OPS")
70
 
71
+ async def ops_scrap_bulk(self, patent_ids):
72
+ self.calls.append(("ops_scrap_bulk", patent_ids))
73
+ return PatentScrapBulkResponse(patents=[], failed_ids=list(patent_ids))
74
 
 
75
 
76
+ # ------------------------------- endpoint dispatch -------------------------------
77
 
 
 
 
78
 
79
+ @pytest.mark.parametrize("path, expected_method", [
80
+ ("/serp/search_scholar", "google_scholar"),
81
+ ("/serp/search_arxiv", "arxiv"),
82
+ ("/serp/search_patents", "patents"),
83
+ ("/serp/search_brave", "brave"),
84
+ ("/serp/search_bing", "bing"),
85
+ ("/serp/search_duck", "duckduckgo"),
86
+ ("/serp/search", "search"),
87
+ ("/ops/search", "ops_keyword_search"),
88
+ ])
89
+ async def test_each_search_endpoint_dispatches_to_its_service_method(
90
+ client, override_services, path, expected_method):
91
+ """Guards the wiring itself: every endpoint reaching the right method
92
+ with the request's queries and result count intact."""
93
+ service = _RecordingSearchService()
94
+ override_services(search=service)
95
 
96
+ resp = await client.post(path, json={"queries": ["a", "b"], "n_results": 25})
97
 
98
  assert resp.status_code == 200
99
+ assert service.calls == [(expected_method, ["a", "b"], 25)]
100
+ assert resp.json()["results"] == [{"title": expected_method}]
 
101
 
102
 
103
+ @pytest.mark.parametrize("method, path, expected_call", [
104
+ ("get", "/scrap/scrap_patent/US11930446B2", ("scrap", "US11930446B2")),
105
+ ("get", "/ops/scrap_patent/US11930446B2", ("ops_scrap", "US11930446B2")),
106
+ ])
107
+ async def test_patent_endpoints_dispatch_to_their_service_method(
108
+ client, override_services, method, path, expected_call):
109
+ service = _StubPatentService()
110
+ override_services(patent=service)
111
 
112
+ resp = await getattr(client, method)(path)
 
 
113
 
114
  assert resp.status_code == 200
115
+ assert service.calls == [expected_call]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
 
 
117
 
118
+ @pytest.mark.parametrize("path, expected_method", [
119
+ ("/scrap/scrap_patents_bulk", "scrap_bulk"),
120
+ ("/ops/scrap_patents_bulk", "ops_scrap_bulk"),
121
+ ])
122
+ async def test_bulk_endpoints_dispatch_to_their_service_method(
123
+ client, override_services, path, expected_method):
124
+ service = _StubPatentService()
125
+ override_services(patent=service)
126
 
127
+ resp = await client.post(path, json={"patent_ids": ["US11930446B2"]})
128
 
129
+ assert resp.status_code == 200
130
+ assert service.calls == [(expected_method, ["US11930446B2"])]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
 
133
  # ------------------------------- patent_id validation -------------------------------
134
 
135
 
136
+ @pytest.mark.parametrize("path", [
137
+ "/scrap/scrap_patent/not-a-patent-id",
138
+ "/ops/scrap_patent/not-a-patent-id",
139
+ ])
140
+ async def test_malformed_patent_id_is_rejected(client, path):
141
+ resp = await client.get(path)
142
  assert resp.status_code == 422
143
 
144
 
145
+ @pytest.mark.parametrize("path", [
146
+ "/scrap/scrap_patents_bulk", "/ops/scrap_patents_bulk"])
147
+ async def test_malformed_patent_id_in_a_bulk_list_is_rejected(client, path):
148
+ resp = await client.post(path, json={"patent_ids": ["US11930446B2", "garbage!!"]})
149
+ assert resp.status_code == 422
150
 
151
+
152
+ async def test_well_formed_patent_id_is_accepted(client, override_services):
153
+ override_services(patent=_StubPatentService())
154
 
155
  resp = await client.get("/scrap/scrap_patent/US11930446B2")
156
 
 
158
  assert resp.json()["title"] == "Widget apparatus"
159
 
160
 
161
+ # ---------------------------- default dependency wiring ----------------------------
 
 
162
 
163
 
164
+ def test_the_default_search_service_gets_the_apps_collaborators():
165
+ """The providers exist so tests can override them; this pins that the
166
+ un-overridden ones still hand the service the real collaborators."""
167
+ service = app_module.get_search_service()
168
 
169
+ assert service._http is app_module.httpx_client
170
+ assert service._breaker is app_module._backend_circuit_breaker
171
+ assert service._ops_tokens is app_module.ops_token_manager
172
 
 
 
 
173
 
174
+ def test_the_search_service_reads_the_browser_lazily(monkeypatch):
175
+ """The browser is started by the lifespan, after the service may already
176
+ exist, and stays None when Playwright fails to start - so the service
177
+ has to read it per call rather than capture it at construction."""
178
+ service = app_module.get_search_service()
179
+ monkeypatch.setattr(app_module, "pw_browser", "a-browser")
180
 
181
+ assert service.browser == "a-browser"
 
 
182
 
 
183
 
184
+ def test_the_default_patent_service_gets_the_apps_collaborators():
185
+ service = app_module.get_patent_service()
186
 
187
+ assert service._http is app_module.httpx_client
188
+ assert service._ops_tokens is app_module.ops_token_manager
 
189
 
190
 
191
  # ------------------------------------- api_lifespan -------------------------------------
192
 
193
 
194
+ class _RecordingClient:
195
+ def __init__(self):
196
+ self.closed = False
197
+
198
+ async def aclose(self):
199
+ self.closed = True
200
+
201
+
202
  async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
203
  """The container runs as a non-root user (see the Dockerfile), and
204
  Chromium's own internal sandbox needs privileges a non-root container
 
232
  monkeypatch.setattr(app_module, "pw_browser", None)
233
  monkeypatch.setattr(app_module, "playwright", None)
234
  monkeypatch.setattr(app_module, "async_playwright", lambda: FakePlaywrightContextManager())
235
+ # The lifespan also closes the shared httpx client on shutdown; swap in
236
+ # a stand-in so this test doesn't close the real one for the rest of
237
+ # the session.
238
+ monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())
239
 
240
  async with app_module.api_lifespan(app_module.app):
241
  pass
 
244
  assert "--no-sandbox" in launch_calls[0].get("args", [])
245
 
246
 
247
+ async def test_api_lifespan_closes_the_http_client_on_shutdown(monkeypatch):
248
+ """The client was created at import and never closed - the browser was
249
+ torn down on shutdown but its connection pool was not.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  """
251
+ recording_client = _RecordingClient()
252
+ monkeypatch.setattr(app_module, "pw_browser", None)
253
+ monkeypatch.setattr(app_module, "playwright", None)
254
+ monkeypatch.setattr(app_module, "httpx_client", recording_client)
255
+ # Playwright failing to start is the documented degraded mode; the
256
+ # client must still be closed cleanly on the way out.
257
+ monkeypatch.setattr(
258
+ app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))
259
 
260
+ async with app_module.api_lifespan(app_module.app):
261
+ pass
 
262
 
263
+ assert recording_client.closed is True
 
264
 
 
 
265
 
266
+ async def test_api_lifespan_survives_playwright_failing_to_start(monkeypatch):
267
+ """Documented behaviour: the app still comes up, pw_browser stays None,
268
+ and the browser-backed endpoints fail individually rather than the whole
269
+ deployment refusing to boot.
270
+ """
271
+ monkeypatch.setattr(app_module, "pw_browser", None)
272
+ monkeypatch.setattr(app_module, "playwright", None)
273
+ monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())
274
+ monkeypatch.setattr(
275
+ app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))
276
 
277
+ async with app_module.api_lifespan(app_module.app):
278
+ assert app_module.pw_browser is None
 
 
 
tests/test_auth.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the optional API-key middleware.
2
+
3
+ This is the only security control in the codebase and had no tests at all:
4
+ mutation testing showed that replacing `secrets.compare_digest` with `==`
5
+ (a timing-oracle regression) or disabling the guard's condition outright
6
+ both left the full suite green. These pin it.
7
+
8
+ `SERPENT_API_KEY` is read into a module global that the middleware resolves
9
+ at call time, so monkeypatching the module attribute is enough - no import
10
+ juggling or module reloading needed.
11
+ """
12
+
13
+ import httpx
14
+ import pytest
15
+ from httpx import ASGITransport
16
+
17
+ import app as app_module
18
+ import services as services_module
19
+
20
+
21
+ @pytest.fixture
22
+ async def client():
23
+ transport = ASGITransport(app=app_module.app)
24
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
25
+ yield c
26
+
27
+
28
+ @pytest.fixture
29
+ def api_key(monkeypatch):
30
+ """Configure a known key for the duration of one test."""
31
+ monkeypatch.setattr(app_module, "SERPENT_API_KEY", "s3cret-key")
32
+ return "s3cret-key"
33
+
34
+
35
+ @pytest.fixture(autouse=True)
36
+ def _unset_by_default(monkeypatch):
37
+ """Every test starts from the shipped default (no key configured), so a
38
+ stray SERPENT_API_KEY in the developer's environment can't change the
39
+ outcome of the tests below.
40
+ """
41
+ monkeypatch.setattr(app_module, "SERPENT_API_KEY", None)
42
+
43
+
44
+ # ------------------------------- disabled by default -------------------------------
45
+
46
+
47
+ async def test_no_key_configured_leaves_the_api_open(client, monkeypatch):
48
+ """The middleware is a no-op unless SERPENT_API_KEY is set - this keeps
49
+ the default deployment exactly as open as it was before it existed.
50
+ """
51
+ monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
52
+
53
+ resp = await client.post("/serp/search_arxiv", json={"queries": ["a"]})
54
+
55
+ assert resp.status_code == 200
56
+
57
+
58
+ async def _stub_arxiv(client_arg, q, n):
59
+ return [{"title": q, "href": "h", "body": "b", "id": "1"}]
60
+
61
+
62
+ # ------------------------------- gating when enabled -------------------------------
63
+
64
+
65
+ async def test_missing_key_is_rejected(client, api_key):
66
+ resp = await client.post("/serp/search_arxiv", json={"queries": ["a"]})
67
+
68
+ assert resp.status_code == 401
69
+ assert "API key" in resp.json()["detail"]
70
+
71
+
72
+ async def test_wrong_key_is_rejected(client, api_key):
73
+ resp = await client.post(
74
+ "/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": "wrong"})
75
+
76
+ assert resp.status_code == 401
77
+
78
+
79
+ async def test_correct_key_via_x_api_key_header_is_accepted(client, api_key, monkeypatch):
80
+ monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
81
+
82
+ resp = await client.post(
83
+ "/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": api_key})
84
+
85
+ assert resp.status_code == 200
86
+
87
+
88
+ async def test_correct_key_via_bearer_header_is_accepted(client, api_key, monkeypatch):
89
+ monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
90
+
91
+ resp = await client.post(
92
+ "/serp/search_arxiv", json={"queries": ["a"]},
93
+ headers={"Authorization": f"Bearer {api_key}"})
94
+
95
+ assert resp.status_code == 200
96
+
97
+
98
+ async def test_bearer_scheme_match_is_case_insensitive(client, api_key, monkeypatch):
99
+ monkeypatch.setattr(services_module, "query_arxiv", _stub_arxiv)
100
+
101
+ resp = await client.post(
102
+ "/serp/search_arxiv", json={"queries": ["a"]},
103
+ headers={"Authorization": f"bearer {api_key}"})
104
+
105
+ assert resp.status_code == 200
106
+
107
+
108
+ # --------------------------------- public paths ---------------------------------
109
+
110
+
111
+ @pytest.mark.parametrize("path", ["/", "/openapi.json"])
112
+ async def test_docs_paths_stay_public(client, api_key, path):
113
+ """The docs UI and the schema it fetches must stay reachable, otherwise
114
+ a key-protected deployment serves an unusable landing page.
115
+ """
116
+ resp = await client.get(path)
117
+
118
+ assert resp.status_code == 200
119
+
120
+
121
+ async def test_a_key_that_is_a_prefix_of_the_real_one_is_rejected(client, api_key):
122
+ """Guards the length check inside the comparison."""
123
+ resp = await client.post(
124
+ "/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": "s3cret"})
125
+
126
+ assert resp.status_code == 401
127
+
128
+
129
+ # ------------------------------ constant-time comparison ------------------------------
130
+
131
+
132
+ async def test_key_comparison_is_constant_time(client, api_key, monkeypatch):
133
+ """`==` and `secrets.compare_digest` are functionally identical - they
134
+ differ only in timing, so no black-box assertion can tell them apart.
135
+ (Confirmed by mutation: swapping in `==` leaves every other test in this
136
+ file green.) A timing oracle on the API key is worth pinning anyway, so
137
+ assert structurally that the comparison goes through compare_digest.
138
+ """
139
+ import secrets as secrets_module
140
+
141
+ calls = []
142
+ real = secrets_module.compare_digest
143
+
144
+ def spy(a, b):
145
+ calls.append((a, b))
146
+ return real(a, b)
147
+
148
+ monkeypatch.setattr(secrets_module, "compare_digest", spy)
149
+
150
+ await client.post(
151
+ "/serp/search_arxiv", json={"queries": ["a"]}, headers={"X-API-Key": "wrong"})
152
+
153
+ assert calls, "the API key must be compared with secrets.compare_digest"
154
+
155
+
156
+ async def test_non_ascii_key_is_rejected_not_crashed(client, api_key):
157
+ """`secrets.compare_digest` raises TypeError ("comparing strings with
158
+ non-ASCII characters is not supported") on str arguments outside ASCII,
159
+ and Starlette decodes headers as latin-1 - so an accented byte in the
160
+ header reached it intact and turned the security control into an
161
+ unhandled 500 for any anonymous caller. It must fail closed with a 401
162
+ like any other wrong key.
163
+
164
+ The header is passed as raw latin-1 bytes because that is what arrives
165
+ off the wire; handing httpx a str with non-ASCII in it fails in the
166
+ client before the request is ever sent, which would test nothing.
167
+ """
168
+ resp = await client.post(
169
+ "/serp/search_arxiv", json={"queries": ["a"]},
170
+ headers={"X-API-Key": "café".encode("latin-1")})
171
+
172
+ assert resp.status_code == 401
173
+
174
+
175
+ async def test_non_ascii_key_is_rejected_via_bearer_header_too(client, api_key):
176
+ resp = await client.post(
177
+ "/serp/search_arxiv", json={"queries": ["a"]},
178
+ headers={"Authorization": "Bearer café".encode("latin-1")})
179
+
180
+ assert resp.status_code == 401
tests/test_input_bounds.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounds on request-shaped input.
2
+
3
+ `n_results` was defended by a validator and a parametrized test while the
4
+ list fields on the same models were undefended entirely: an empty query
5
+ list crashed every search endpoint with an IndexError, and the bulk
6
+ endpoints would fan out one concurrent outbound request per id with no
7
+ upper limit at all.
8
+ """
9
+
10
+ import httpx
11
+ import pytest
12
+ from httpx import ASGITransport
13
+ from pydantic import ValidationError
14
+
15
+ import app as app_module
16
+ import scrap as scrap_module
17
+ from app import MAX_BULK_PATENT_IDS, ScrapPatentsRequest
18
+ from serp import MAX_QUERIES_PER_REQUEST, SerpQuery
19
+ from services import shape_serp_results
20
+
21
+
22
+ @pytest.fixture
23
+ async def client():
24
+ transport = ASGITransport(app=app_module.app)
25
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
26
+ yield c
27
+
28
+
29
+ # --------------------------------- query list bounds ---------------------------------
30
+
31
+
32
+ def test_empty_query_list_is_rejected_by_the_model():
33
+ with pytest.raises(ValidationError):
34
+ SerpQuery(queries=[])
35
+
36
+
37
+ def test_too_many_queries_are_rejected_by_the_model():
38
+ with pytest.raises(ValidationError):
39
+ SerpQuery(queries=["q"] * (MAX_QUERIES_PER_REQUEST + 1))
40
+
41
+
42
+ async def test_empty_query_list_returns_422_not_500(client):
43
+ """`_shape_serp_results` read `results[-1]` to build its error message,
44
+ so an empty list raised IndexError - an unhandled 500 on all six search
45
+ endpoints, reachable with a two-character request body.
46
+ """
47
+ resp = await client.post("/serp/search_arxiv", json={"queries": []})
48
+
49
+ assert resp.status_code == 422
50
+
51
+
52
+ async def test_oversized_query_list_returns_422(client):
53
+ resp = await client.post(
54
+ "/serp/search_arxiv", json={"queries": ["q"] * (MAX_QUERIES_PER_REQUEST + 1)})
55
+
56
+ assert resp.status_code == 422
57
+
58
+
59
+ def test_shape_serp_results_is_total_for_an_empty_list():
60
+ """Defence in depth: the helper must not depend on its caller having
61
+ validated the query list, since it is shared by every search path.
62
+ """
63
+ result = shape_serp_results([])
64
+
65
+ assert result.results == []
66
+ assert result.error is not None
67
+
68
+
69
+ # ---------------------------------- bulk id bounds ----------------------------------
70
+
71
+
72
+ def test_empty_patent_id_list_is_rejected():
73
+ with pytest.raises(ValidationError):
74
+ ScrapPatentsRequest(patent_ids=[])
75
+
76
+
77
+ def test_too_many_patent_ids_are_rejected():
78
+ with pytest.raises(ValidationError):
79
+ ScrapPatentsRequest(patent_ids=["US1234567"] * (MAX_BULK_PATENT_IDS + 1))
80
+
81
+
82
+ async def test_oversized_bulk_request_returns_422(client):
83
+ resp = await client.post(
84
+ "/scrap/scrap_patents_bulk",
85
+ json={"patent_ids": ["US1234567"] * (MAX_BULK_PATENT_IDS + 1)})
86
+
87
+ assert resp.status_code == 422
88
+
89
+
90
+ # ------------------------------- bulk fan-out is bounded -------------------------------
91
+
92
+
93
+ async def test_bulk_scrape_limits_concurrent_outbound_requests(monkeypatch):
94
+ """Playwright work is bounded by a semaphore; HTTP work was not bounded
95
+ at all, so a single accepted request could open one outbound scrape per
96
+ id. Track how many are in flight simultaneously.
97
+ """
98
+ import asyncio
99
+
100
+ in_flight = 0
101
+ peak = 0
102
+
103
+ async def slow_scrape(client_arg, url):
104
+ nonlocal in_flight, peak
105
+ in_flight += 1
106
+ peak = max(peak, in_flight)
107
+ await asyncio.sleep(0.01)
108
+ in_flight -= 1
109
+ raise httpx.ConnectTimeout("nope")
110
+
111
+ monkeypatch.setattr(scrap_module, "scrap_patent_async", slow_scrape)
112
+
113
+ ids = [f"US{1000000 + i}" for i in range(40)]
114
+ result = await scrap_module.scrap_patent_bulk_async(None, ids)
115
+
116
+ assert peak <= scrap_module.BULK_SCRAP_CONCURRENCY_LIMIT, (
117
+ f"{peak} concurrent scrapes for {len(ids)} ids; "
118
+ f"limit is {scrap_module.BULK_SCRAP_CONCURRENCY_LIMIT}")
119
+ assert result.failed_ids == ids
120
+
121
+
122
+ async def test_bulk_ops_retrieval_limits_concurrent_requests(monkeypatch):
123
+ """The OPS bulk path is the expensive one - three requests per id
124
+ (biblio, claims, description) - so it carries a tighter limit.
125
+ """
126
+ import asyncio
127
+
128
+ import ops as ops_module
129
+
130
+ in_flight = 0
131
+ peak = 0
132
+
133
+ async def slow_ops_scrap(client_arg, number, *args, **kwargs):
134
+ nonlocal in_flight, peak
135
+ in_flight += 1
136
+ peak = max(peak, in_flight)
137
+ await asyncio.sleep(0.01)
138
+ in_flight -= 1
139
+ raise RuntimeError("nope")
140
+
141
+ monkeypatch.setattr(ops_module, "ops_scrap_patent", slow_ops_scrap)
142
+
143
+ numbers = [f"US{1000000 + i}" for i in range(30)]
144
+ result = await ops_module.ops_scrap_patent_bulk(None, numbers)
145
+
146
+ assert peak <= ops_module.BULK_OPS_CONCURRENCY_LIMIT, (
147
+ f"{peak} concurrent OPS retrievals; limit is "
148
+ f"{ops_module.BULK_OPS_CONCURRENCY_LIMIT}")
149
+ assert result.failed_ids == numbers
tests/test_mcp_server.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The MCP tool surface.
2
+
3
+ mcp_server.py had no tests, and it degrades silently. `_tool_names` walks
4
+ the route tree through three version-dependent shapes - DefaultPlaceholder
5
+ unwrapping, nested `.routes`, and FastAPI >= 0.141's `original_router` -
6
+ each a reasonable guess about a library's internals. If any stops
7
+ resolving, tools quietly revert to names like
8
+ `search_arxiv_serp_search_arxiv_post`, tool selection degrades, and
9
+ nothing goes red.
10
+
11
+ These pin the surface an agent actually sees, so a dependency bump reports
12
+ the breakage instead of shipping it.
13
+ """
14
+
15
+ import pytest
16
+
17
+ import app as app_module
18
+ from mcp_server import EXCLUDED_ROUTES, _tool_names
19
+
20
+ # The curated set. /ops/* and the single-backend search endpoints are
21
+ # deliberately excluded (see EXCLUDED_ROUTES): they stay available over
22
+ # REST, but exposing them as tools invites a model to pick a path that
23
+ # skips the fallback chains.
24
+ EXPECTED_TOOLS = {
25
+ "search",
26
+ "search_arxiv",
27
+ "search_google_scholar",
28
+ "search_patents",
29
+ "scrap_patent",
30
+ "scrap_patents",
31
+ }
32
+
33
+
34
+ @pytest.fixture
35
+ async def tools():
36
+ return {t.name: t for t in await app_module.mcp.list_tools()}
37
+
38
+
39
+ async def test_the_exposed_tool_set_is_the_curated_one(tools):
40
+ assert set(tools) == EXPECTED_TOOLS
41
+
42
+
43
+ async def test_tool_names_are_handler_names_not_generated_operation_ids(tools):
44
+ """FastAPI's generated operationIds (`search_arxiv_serp_search_arxiv_post`)
45
+ are what an LLM would otherwise see. _tool_names maps them back to the
46
+ Python handler name.
47
+ """
48
+ for name in tools:
49
+ assert "_post" not in name and "_get" not in name
50
+ assert "_serp_" not in name and "_scrap_" not in name
51
+
52
+
53
+ async def test_operation_ids_all_resolve_to_a_handler_name():
54
+ """If the route walk stops finding routes, this mapping silently
55
+ empties and every tool falls back to its generated operationId."""
56
+ names = _tool_names(app_module.app)
57
+
58
+ assert len(names) >= len(EXPECTED_TOOLS)
59
+ assert "search_arxiv_serp_search_arxiv_post" in names
60
+ assert names["search_arxiv_serp_search_arxiv_post"] == "search_arxiv"
61
+
62
+
63
+ @pytest.mark.parametrize("excluded", [
64
+ "ops_keyword_search", "ops_get_patent", "ops_get_patents_bulk",
65
+ "search_brave", "search_bing", "search_duck",
66
+ ])
67
+ async def test_excluded_endpoints_are_not_exposed_as_tools(tools, excluded):
68
+ assert excluded not in tools
69
+
70
+
71
+ def test_excluded_routes_patterns_are_anchored():
72
+ """An unanchored pattern would silently exclude more than intended."""
73
+ for pattern in EXCLUDED_ROUTES:
74
+ assert pattern.startswith("^")
75
+
76
+
77
+ # ---------------------------------- output schemas ----------------------------------
78
+
79
+
80
+ @pytest.mark.parametrize("tool_name", sorted(EXPECTED_TOOLS))
81
+ async def test_every_tool_declares_its_output_schema(tools, tool_name):
82
+ """FastMCP builds tool schemas from the app's OpenAPI document, so a
83
+ handler with no return annotation advertises an untyped result to the
84
+ model. `search` - the tool the instructions push agents toward first -
85
+ was one of three that did.
86
+ """
87
+ assert tools[tool_name].output_schema, (
88
+ f"tool '{tool_name}' advertises no output schema; "
89
+ "the handler is probably missing a return annotation")
90
+
91
+
92
+ @pytest.mark.parametrize("path", [
93
+ "/serp/search", "/serp/search_arxiv", "/serp/search_scholar",
94
+ "/serp/search_patents", "/scrap/scrap_patent/{patent_id}",
95
+ "/scrap/scrap_patents_bulk",
96
+ ])
97
+ def test_every_exposed_route_declares_a_200_response_model(path):
98
+ schema = app_module.app.openapi()
99
+ for operation in schema["paths"][path].values():
100
+ content = (operation.get("responses", {}).get("200", {})
101
+ .get("content", {}).get("application/json", {}).get("schema", {}))
102
+ assert content, f"{path} has no 200 response schema"
tests/test_patent_fallback.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Google Patents -> EPO OPS fallback chain.
2
+
3
+ The policy lives in services.PatentService, so most of this is plain unit
4
+ testing with no ASGI client involved. Only the last section goes through
5
+ the app, and only to check that the domain errors the service raises map
6
+ onto the right status codes.
7
+
8
+ The distinction these tests exist to protect: a 404 means "this patent does
9
+ not exist", and an agent is explicitly told by MCP_INSTRUCTIONS to believe
10
+ it and move on without retrying. Anything else - an upstream outage, a
11
+ timeout, a blocked scrape - must not be reported that way.
12
+ """
13
+
14
+ import httpx
15
+ import pytest
16
+ from httpx import ASGITransport, HTTPStatusError, Request, Response
17
+
18
+ import app as app_module
19
+ import services as services_module
20
+ from scrap import PatentScrapResult
21
+ from services import (OPSUnconfigured, PatentNotFound, PatentService,
22
+ UpstreamUnavailable)
23
+
24
+ PATENT_ID = "US11930446B2"
25
+
26
+
27
+ class FakeOPSTokens:
28
+ """Stand-in for the OPS token manager. Tests only care whether
29
+ credentials are configured, and this avoids assigning to private
30
+ attributes on the real module-level singleton.
31
+ """
32
+
33
+ def __init__(self, configured: bool):
34
+ self.configured = configured
35
+
36
+
37
+ def make_service(*, ops_configured: bool) -> PatentService:
38
+ return PatentService(http_client=None, ops_tokens=FakeOPSTokens(ops_configured))
39
+
40
+
41
+ def _http_error(status: int) -> HTTPStatusError:
42
+ request = Request("GET", "https://patents.google.com/")
43
+ return HTTPStatusError(
44
+ str(status), request=request, response=Response(status, request=request))
45
+
46
+
47
+ def _raises(exc):
48
+ async def _fn(*args, **kwargs):
49
+ raise exc
50
+ return _fn
51
+
52
+
53
+ def _returns(value):
54
+ async def _fn(*args, **kwargs):
55
+ return value
56
+ return _fn
57
+
58
+
59
+ # ------------------------------- the happy path -------------------------------
60
+
61
+
62
+ async def test_successful_scrape_never_touches_ops(monkeypatch):
63
+ ops_called = False
64
+
65
+ async def fake_ops(*args, **kwargs):
66
+ nonlocal ops_called
67
+ ops_called = True
68
+ return PatentScrapResult(title="from OPS")
69
+
70
+ monkeypatch.setattr(
71
+ services_module, "scrap_patent_async",
72
+ _returns(PatentScrapResult(title="from Google Patents")))
73
+ monkeypatch.setattr(services_module, "ops_scrap_patent", fake_ops)
74
+
75
+ result = await make_service(ops_configured=True).scrap(PATENT_ID)
76
+
77
+ assert result.title == "from Google Patents"
78
+ assert ops_called is False
79
+
80
+
81
+ # ------------------------- genuine miss vs upstream failure -------------------------
82
+
83
+
84
+ async def test_a_real_404_from_google_patents_is_not_found(monkeypatch):
85
+ """The one case where "not found" is the truth."""
86
+ monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(404)))
87
+
88
+ with pytest.raises(PatentNotFound):
89
+ await make_service(ops_configured=False).scrap(PATENT_ID)
90
+
91
+
92
+ @pytest.mark.parametrize("status", [429, 500, 502, 503])
93
+ async def test_an_upstream_failure_is_not_reported_as_not_found(monkeypatch, status):
94
+ """MCP_INSTRUCTIONS tells the model a "not found" patent is genuinely
95
+ absent everywhere and to move on rather than retrying. Reporting a
96
+ transient upstream failure that way teaches an agent - with the
97
+ server's explicit encouragement - that a real patent does not exist.
98
+ """
99
+ monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(status)))
100
+
101
+ with pytest.raises(UpstreamUnavailable) as exc_info:
102
+ await make_service(ops_configured=False).scrap(PATENT_ID)
103
+
104
+ assert exc_info.value.timeout is False
105
+
106
+
107
+ async def test_a_timeout_is_flagged_as_a_timeout(monkeypatch):
108
+ monkeypatch.setattr(
109
+ services_module, "scrap_patent_async", _raises(httpx.ConnectTimeout("timed out")))
110
+
111
+ with pytest.raises(UpstreamUnavailable) as exc_info:
112
+ await make_service(ops_configured=False).scrap(PATENT_ID)
113
+
114
+ assert exc_info.value.timeout is True
115
+
116
+
117
+ async def test_an_unparseable_page_is_an_upstream_failure(monkeypatch):
118
+ """parse_patent_html raises ValueError when the page isn't a patent page
119
+ (an interstitial, or a markup change). That is our problem or theirs,
120
+ but it is not evidence the patent doesn't exist.
121
+ """
122
+ monkeypatch.setattr(
123
+ services_module, "scrap_patent_async", _raises(ValueError("no DC.title")))
124
+
125
+ with pytest.raises(UpstreamUnavailable):
126
+ await make_service(ops_configured=False).scrap(PATENT_ID)
127
+
128
+
129
+ # --------------------------------- the OPS fallback ---------------------------------
130
+
131
+
132
+ async def test_falls_back_to_ops_when_google_patents_fails(monkeypatch):
133
+ monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(503)))
134
+ monkeypatch.setattr(
135
+ services_module, "ops_scrap_patent", _returns(PatentScrapResult(title="from OPS")))
136
+
137
+ result = await make_service(ops_configured=True).scrap(PATENT_ID)
138
+
139
+ assert result.title == "from OPS"
140
+
141
+
142
+ async def test_missing_from_both_backends_is_not_found(monkeypatch):
143
+ monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(404)))
144
+ monkeypatch.setattr(services_module, "ops_scrap_patent", _raises(_http_error(404)))
145
+
146
+ with pytest.raises(PatentNotFound):
147
+ await make_service(ops_configured=True).scrap(PATENT_ID)
148
+
149
+
150
+ async def test_ops_failing_after_a_google_patents_404_is_not_a_miss(monkeypatch):
151
+ """Google Patents says the patent is missing, but OPS - the backend that
152
+ covers what Google Patents doesn't - never answered. That is unknown,
153
+ not absent.
154
+ """
155
+ monkeypatch.setattr(services_module, "scrap_patent_async", _raises(_http_error(404)))
156
+ monkeypatch.setattr(services_module, "ops_scrap_patent", _raises(_http_error(500)))
157
+
158
+ with pytest.raises(UpstreamUnavailable):
159
+ await make_service(ops_configured=True).scrap(PATENT_ID)
160
+
161
+
162
+ # ------------------------------ the OPS-only path ------------------------------
163
+
164
+
165
+ async def test_ops_only_retrieval_requires_credentials():
166
+ with pytest.raises(OPSUnconfigured):
167
+ await make_service(ops_configured=False).ops_scrap(PATENT_ID)
168
+
169
+
170
+ async def test_ops_only_retrieval_reports_a_real_miss(monkeypatch):
171
+ """Here OPS is the only backend asked, so its 404 is the whole answer."""
172
+ monkeypatch.setattr(services_module, "ops_scrap_patent", _raises(_http_error(404)))
173
+
174
+ with pytest.raises(PatentNotFound):
175
+ await make_service(ops_configured=True).ops_scrap(PATENT_ID)
176
+
177
+
178
+ async def test_ops_only_retrieval_flags_a_timeout(monkeypatch):
179
+ monkeypatch.setattr(
180
+ services_module, "ops_scrap_patent", _raises(httpx.ConnectTimeout("timed out")))
181
+
182
+ with pytest.raises(UpstreamUnavailable) as exc_info:
183
+ await make_service(ops_configured=True).ops_scrap(PATENT_ID)
184
+
185
+ assert exc_info.value.timeout is True
186
+
187
+
188
+ # ---------------------------- domain error -> status code ----------------------------
189
+
190
+
191
+ @pytest.fixture
192
+ async def client():
193
+ transport = ASGITransport(app=app_module.app)
194
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
195
+ yield c
196
+
197
+
198
+ @pytest.fixture
199
+ def override_patent_service():
200
+ """Swap the app's patent service for one the test controls, then put the
201
+ real provider back."""
202
+ def _install(service):
203
+ app_module.app.dependency_overrides[app_module.get_patent_service] = lambda: service
204
+
205
+ yield _install
206
+ app_module.app.dependency_overrides.clear()
207
+
208
+
209
+ class _StubService:
210
+ def __init__(self, error):
211
+ self._error = error
212
+
213
+ async def scrap(self, patent_id):
214
+ raise self._error
215
+
216
+ ops_scrap = scrap
217
+
218
+
219
+ @pytest.mark.parametrize("error, expected_status", [
220
+ (PatentNotFound("gone"), 404),
221
+ (UpstreamUnavailable("upstream broke"), 502),
222
+ (UpstreamUnavailable("slow", timeout=True), 504),
223
+ (OPSUnconfigured("no credentials"), 503),
224
+ ])
225
+ async def test_domain_errors_map_onto_status_codes(
226
+ client, override_patent_service, error, expected_status):
227
+ override_patent_service(_StubService(error))
228
+
229
+ resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
230
+
231
+ assert resp.status_code == expected_status
232
+ assert resp.json()["detail"] == str(error)
tests/test_scrap.py CHANGED
@@ -89,3 +89,15 @@ async def test_scrap_patent_bulk_async_separates_successes_from_failures():
89
  assert len(result.patents) == 1
90
  assert result.patents[0].title == "Widget with improved gadget mechanism"
91
  assert result.failed_ids == ["US_FAIL"]
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  assert len(result.patents) == 1
90
  assert result.patents[0].title == "Widget with improved gadget mechanism"
91
  assert result.failed_ids == ["US_FAIL"]
92
+
93
+
94
+ def test_parse_patent_html_strips_whitespace_around_the_title():
95
+ """Google Patents' DC.title meta content is frequently padded with
96
+ newlines and indentation from the surrounding markup, which would
97
+ otherwise end up in the API response and in every downstream citation.
98
+ """
99
+ html = '<html><head><meta name="DC.title" content=" Widget apparatus\n "></head><body></body></html>'
100
+
101
+ result = parse_patent_html(html, "https://patents.google.com/patent/US1/en")
102
+
103
+ assert result.title == "Widget apparatus"
tests/test_search_service.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SearchService: the backend orchestration policy.
2
+
3
+ Which backend to try, in what order, when to fall back, and what counts as
4
+ evidence a backend is unhealthy. None of this needs an HTTP layer, so none
5
+ of these tests use one.
6
+
7
+ Each test builds a service with its own circuit breaker. That replaces the
8
+ autouse fixture that used to reset a process-wide singleton between tests -
9
+ the breaker being shared across every request is correct in production and
10
+ was only a problem because tests had no way to opt out of it.
11
+ """
12
+
13
+
14
+ import services as services_module
15
+ from circuit_breaker import CircuitBreaker
16
+ from serp import DuckDuckGoBlockedException, SerpQuery
17
+ from services import SearchService, shape_serp_results
18
+
19
+
20
+ class FakeOPSTokens:
21
+ def __init__(self, configured: bool):
22
+ self.configured = configured
23
+
24
+
25
+ def make_service(*, ops_configured: bool = False, breaker: CircuitBreaker = None,
26
+ browser=None) -> SearchService:
27
+ return SearchService(
28
+ http_client=None,
29
+ browser_provider=lambda: browser,
30
+ circuit_breaker=breaker or CircuitBreaker(),
31
+ ops_tokens=FakeOPSTokens(ops_configured),
32
+ )
33
+
34
+
35
+ # --------------------------------- result shaping ---------------------------------
36
+
37
+
38
+ class TestShapeSerpResults:
39
+ def test_flattens_successful_lists(self):
40
+ result = shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
41
+ assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
42
+ assert result.error is None
43
+
44
+ def test_surfaces_last_error_when_all_failed(self):
45
+ result = shape_serp_results([ValueError("first"), RuntimeError("second")])
46
+ assert result.results == []
47
+ assert "second" in result.error
48
+
49
+ def test_ignores_failures_when_some_succeed(self):
50
+ result = shape_serp_results([[{"a": 1}], RuntimeError("boom")])
51
+ assert result.results == [{"a": 1}]
52
+ assert result.error is None
53
+
54
+ def test_is_total_for_an_empty_list(self):
55
+ """Defence in depth: the request models bound the query list, but
56
+ this helper is shared by every search path and must not depend on
57
+ its caller having validated anything."""
58
+ result = shape_serp_results([])
59
+ assert result.results == []
60
+ assert result.error is not None
61
+
62
+
63
+ # ------------------------------- per-query dispatch -------------------------------
64
+
65
+
66
+ async def test_every_query_is_dispatched_with_the_result_count():
67
+ calls = []
68
+
69
+ async def fn(q, n):
70
+ calls.append((q, n))
71
+ return [{"q": q}]
72
+
73
+ result = await make_service()._run_queries(
74
+ fn, SerpQuery(queries=["x", "y"], n_results=5), "test context")
75
+
76
+ assert sorted(calls) == [("x", 5), ("y", 5)]
77
+ assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
78
+
79
+
80
+ async def test_arxiv_flattens_results_from_all_queries(monkeypatch):
81
+ async def fake_query_arxiv(client, q, n):
82
+ return [{"title": f"paper about {q}"}]
83
+
84
+ monkeypatch.setattr(services_module, "query_arxiv", fake_query_arxiv)
85
+
86
+ result = await make_service().arxiv(SerpQuery(queries=["a", "b"], n_results=5))
87
+
88
+ assert {r["title"] for r in result.results} == {"paper about a", "paper about b"}
89
+ assert result.error is None
90
+
91
+
92
+ async def test_arxiv_reports_an_error_when_every_query_fails(monkeypatch):
93
+ async def failing(client, q, n):
94
+ raise RuntimeError(f"boom for {q}")
95
+
96
+ monkeypatch.setattr(services_module, "query_arxiv", failing)
97
+
98
+ result = await make_service().arxiv(SerpQuery(queries=["a"], n_results=5))
99
+
100
+ assert result.results == []
101
+ assert "boom for a" in result.error
102
+
103
+
104
+ # ------------------------------ patents + OPS fallback ------------------------------
105
+
106
+
107
+ async def test_patents_does_not_fall_back_when_google_patents_succeeds(monkeypatch):
108
+ ops_called = False
109
+
110
+ async def fake_ops(client, q, n):
111
+ nonlocal ops_called
112
+ ops_called = True
113
+ return []
114
+
115
+ monkeypatch.setattr(
116
+ services_module, "query_google_patents",
117
+ lambda b, q, n: _ok([{"id": "US1", "title": "t"}]))
118
+ monkeypatch.setattr(services_module, "ops_search", fake_ops)
119
+
120
+ result = await make_service(ops_configured=True).patents(
121
+ SerpQuery(queries=["widget"], n_results=5))
122
+
123
+ assert result.results == [{"id": "US1", "title": "t"}]
124
+ assert ops_called is False
125
+
126
+
127
+ async def _ok(value):
128
+ return value
129
+
130
+
131
+ async def test_patents_falls_back_to_ops_when_google_patents_is_empty(monkeypatch):
132
+ monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
133
+ monkeypatch.setattr(
134
+ services_module, "ops_search", lambda c, q, n: _ok([{"id": "US123", "title": "OPS"}]))
135
+
136
+ result = await make_service(ops_configured=True).patents(
137
+ SerpQuery(queries=["widget"], n_results=5))
138
+
139
+ assert result.results == [{"id": "US123", "title": "OPS"}]
140
+
141
+
142
+ async def test_patents_does_not_fall_back_when_ops_is_unconfigured(monkeypatch):
143
+ monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
144
+
145
+ result = await make_service(ops_configured=False).patents(
146
+ SerpQuery(queries=["widget"], n_results=5))
147
+
148
+ assert result.results == []
149
+ assert result.error is None
150
+
151
+
152
+ async def test_ops_fallback_runs_concurrently_not_one_query_at_a_time(monkeypatch):
153
+ """This path exists for when Google Patents is unavailable, which is
154
+ exactly when a request can least afford N sequential round-trips to the
155
+ slower backend."""
156
+ import asyncio
157
+
158
+ in_flight = 0
159
+ peak = 0
160
+
161
+ async def slow_ops(client, q, n):
162
+ nonlocal in_flight, peak
163
+ in_flight += 1
164
+ peak = max(peak, in_flight)
165
+ await asyncio.sleep(0.02)
166
+ in_flight -= 1
167
+ return [{"id": q}]
168
+
169
+ monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
170
+ monkeypatch.setattr(services_module, "ops_search", slow_ops)
171
+
172
+ await make_service(ops_configured=True).patents(
173
+ SerpQuery(queries=["a", "b", "c", "d"], n_results=5))
174
+
175
+ assert peak > 1, "OPS fallback queries were issued one at a time"
176
+
177
+
178
+ async def test_a_failing_ops_fallback_does_not_lose_other_queries_results(monkeypatch):
179
+ async def selective_ops(client, q, n):
180
+ if q == "bad":
181
+ raise RuntimeError("OPS down")
182
+ return [{"id": q}]
183
+
184
+ monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
185
+ monkeypatch.setattr(services_module, "ops_search", selective_ops)
186
+
187
+ result = await make_service(ops_configured=True).patents(
188
+ SerpQuery(queries=["good", "bad"], n_results=5))
189
+
190
+ assert result.results == [{"id": "good"}]
191
+
192
+
193
+ # -------------------------------- the fallback chain --------------------------------
194
+
195
+
196
+ async def test_returns_the_first_backend_that_succeeds(monkeypatch):
197
+ calls = []
198
+
199
+ async def fake_ddg(q, n):
200
+ calls.append("ddg")
201
+ return [{"title": "from ddg"}]
202
+
203
+ async def fake_brave(browser, q, n):
204
+ calls.append("brave")
205
+ return [{"title": "from brave"}]
206
+
207
+ monkeypatch.setattr(services_module, "query_ddg_search", fake_ddg)
208
+ monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
209
+
210
+ q, results, err = await make_service()._search_one("widget", 5)
211
+
212
+ assert calls == ["ddg"]
213
+ assert results == [{"title": "from ddg"}]
214
+ assert err is None
215
+
216
+
217
+ async def test_falls_through_to_the_next_backend_on_failure(monkeypatch):
218
+ calls = []
219
+
220
+ async def failing_ddg(q, n):
221
+ calls.append("ddg")
222
+ raise RuntimeError("ddg blocked")
223
+
224
+ async def fake_brave(browser, q, n):
225
+ calls.append("brave")
226
+ return [{"title": "from brave"}]
227
+
228
+ monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
229
+ monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
230
+
231
+ q, results, err = await make_service()._search_one("widget", 5)
232
+
233
+ assert calls == ["ddg", "brave"]
234
+ assert results == [{"title": "from brave"}]
235
+ assert err is None
236
+
237
+
238
+ async def test_reports_an_error_when_every_backend_fails(monkeypatch):
239
+ async def failing(*args):
240
+ raise RuntimeError("blocked")
241
+
242
+ for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
243
+ monkeypatch.setattr(services_module, name, failing)
244
+
245
+ q, results, err = await make_service()._search_one("widget", 5)
246
+
247
+ assert results == []
248
+ assert "All backends failed for query 'widget'" in err
249
+ assert "blocked" in err
250
+
251
+
252
+ # ------------------------------ the circuit breaker ------------------------------
253
+
254
+
255
+ async def test_skips_a_backend_whose_circuit_is_open(monkeypatch):
256
+ """A backend that's failed `failure_threshold` times in a row shouldn't
257
+ be attempted again until its cooldown elapses - repeatedly hammering a
258
+ backend that's already rate-limiting us only makes that worse.
259
+ """
260
+ ddg_calls = []
261
+
262
+ async def failing_ddg(q, n):
263
+ ddg_calls.append(q)
264
+ raise RuntimeError("ddg blocked")
265
+
266
+ async def fake_brave(browser, q, n):
267
+ return [{"title": "from brave"}]
268
+
269
+ monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
270
+ monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
271
+
272
+ service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
273
+
274
+ await service._search_one("first query", 5)
275
+ assert ddg_calls == ["first query"]
276
+
277
+ q, results, err = await service._search_one("second query", 5)
278
+
279
+ assert ddg_calls == ["first query"] # unchanged - DDG was skipped
280
+ assert results == [{"title": "from brave"}]
281
+
282
+
283
+ async def test_a_zero_result_query_does_not_open_the_circuit(monkeypatch):
284
+ """An empty result is ambiguous - it can mean the query genuinely has no
285
+ hits, or that the backend served an anti-bot page that parsed to
286
+ nothing. The breaker is shared across every request, so counting the
287
+ ambiguous case meant a few unusual-but-legitimate queries could disable
288
+ the primary backend for everyone for a full cooldown.
289
+ """
290
+ ddg_calls = []
291
+
292
+ async def empty_ddg(q, n):
293
+ ddg_calls.append(q)
294
+ raise DuckDuckGoBlockedException()
295
+
296
+ monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
297
+ monkeypatch.setattr(
298
+ services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
299
+
300
+ service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
301
+
302
+ await service._search_one("obscure", 5)
303
+ await service._search_one("another obscure", 5)
304
+
305
+ assert ddg_calls == ["obscure", "another obscure"]
306
+
307
+
308
+ async def test_an_empty_result_still_falls_through_to_the_next_backend(monkeypatch):
309
+ """Not counting it against the backend must not stop the fallback: the
310
+ caller still needs results from somewhere."""
311
+ async def empty_ddg(q, n):
312
+ raise DuckDuckGoBlockedException()
313
+
314
+ monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
315
+ monkeypatch.setattr(
316
+ services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "from brave"}]))
317
+
318
+ _q, results, err = await make_service()._search_one("widget", 5)
319
+
320
+ assert results == [{"title": "from brave"}]
321
+ assert err is None
322
+
323
+
324
+ async def test_a_real_backend_error_still_opens_the_circuit(monkeypatch):
325
+ """The distinction has to cut both ways: a backend raising a genuine
326
+ error (a rate limit, a transport failure) is still evidence."""
327
+ ddg_calls = []
328
+
329
+ async def failing_ddg(q, n):
330
+ ddg_calls.append(q)
331
+ raise RuntimeError("rate limited")
332
+
333
+ monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
334
+ monkeypatch.setattr(
335
+ services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
336
+
337
+ service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
338
+
339
+ await service._search_one("first", 5)
340
+ await service._search_one("second", 5)
341
+
342
+ assert ddg_calls == ["first"]
343
+
344
+
345
+ # ------------------------------- search aggregation -------------------------------
346
+
347
+
348
+ async def test_search_merges_results_from_every_query(monkeypatch):
349
+ monkeypatch.setattr(
350
+ services_module, "query_ddg_search", lambda q, n: _ok([{"title": f"result for {q}"}]))
351
+
352
+ result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5))
353
+
354
+ assert {r["title"] for r in result.results} == {"result for a", "result for b"}
355
+ assert result.error is None
356
+
357
+
358
+ async def test_search_reports_partial_failure_alongside_results(monkeypatch):
359
+ """A query no backend could answer must be reported even when another
360
+ query succeeded - otherwise the caller silently receives fewer results
361
+ than they asked for with no indication why."""
362
+ async def selective_ddg(q, n):
363
+ if q == "bad":
364
+ raise RuntimeError("blocked")
365
+ return [{"title": f"result for {q}"}]
366
+
367
+ async def failing(*args):
368
+ raise RuntimeError("blocked")
369
+
370
+ monkeypatch.setattr(services_module, "query_ddg_search", selective_ddg)
371
+ monkeypatch.setattr(services_module, "query_brave_search", failing)
372
+ monkeypatch.setattr(services_module, "query_bing_search", failing)
373
+
374
+ result = await make_service().search(SerpQuery(queries=["good", "bad"], n_results=5))
375
+
376
+ assert [r["title"] for r in result.results] == ["result for good"]
377
+ assert "bad" in result.error
378
+
379
+
380
+ async def test_search_names_every_failed_query_not_just_the_last(monkeypatch):
381
+ async def failing(*args):
382
+ raise RuntimeError("blocked")
383
+
384
+ for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
385
+ monkeypatch.setattr(services_module, name, failing)
386
+
387
+ result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5))
388
+
389
+ assert result.results == []
390
+ assert "'a'" in result.error and "'b'" in result.error
tests/test_serp_ddg_concurrency.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The DuckDuckGo backend must not block the event loop.
2
+
3
+ `DDGS.text()` is a synchronous, blocking method. Calling it directly from
4
+ an `async def` freezes the whole process for the duration of the HTTP
5
+ request: no other request progresses, no in-flight Playwright scrape
6
+ progresses, and nothing else on the loop gets a turn.
7
+
8
+ That matters more here than it looks. DuckDuckGo is the *first* backend
9
+ tried for every query in `/serp/search`, which is the flagship MCP tool,
10
+ and both the endpoint docstrings and MCP_INSTRUCTIONS actively encourage
11
+ clients to batch many queries into one call - which under a blocking
12
+ implementation serializes into N sequential stalls instead of N concurrent
13
+ requests.
14
+
15
+ These tests use a blocking stand-in with a known duration rather than the
16
+ real library, so they assert the concurrency property itself and stay fast
17
+ and offline.
18
+ """
19
+
20
+ import asyncio
21
+ import time
22
+
23
+ import serp
24
+ from serp import query_ddg_search
25
+
26
+ BLOCK_SECONDS = 0.25
27
+
28
+
29
+ class _BlockingDDGS:
30
+ """Stands in for the real DDGS: a synchronous call that takes real time."""
31
+
32
+ def text(self, q, max_results=10):
33
+ time.sleep(BLOCK_SECONDS)
34
+ return [{"title": q, "body": "b", "href": "h"}]
35
+
36
+
37
+ async def test_concurrent_queries_do_not_serialize(monkeypatch):
38
+ """Four gathered queries should take about as long as one, not four."""
39
+ monkeypatch.setattr(serp, "DDGS", lambda: _BlockingDDGS())
40
+
41
+ started = time.perf_counter()
42
+ await asyncio.gather(*[query_ddg_search(f"q{i}", 5) for i in range(4)])
43
+ elapsed = time.perf_counter() - started
44
+
45
+ assert elapsed < BLOCK_SECONDS * 2, (
46
+ f"4 concurrent queries took {elapsed:.2f}s; "
47
+ f"serialized would be ~{BLOCK_SECONDS * 4:.2f}s, concurrent ~{BLOCK_SECONDS:.2f}s")
48
+
49
+
50
+ async def test_the_event_loop_stays_responsive_during_a_query(monkeypatch):
51
+ """Anything else scheduled on the loop must keep getting turns while a
52
+ DuckDuckGo query is in flight - this is what a blocked loop costs every
53
+ other request being served at the same time.
54
+ """
55
+ monkeypatch.setattr(serp, "DDGS", lambda: _BlockingDDGS())
56
+
57
+ ticks = 0
58
+ stop = asyncio.Event()
59
+
60
+ async def heartbeat():
61
+ nonlocal ticks
62
+ while not stop.is_set():
63
+ await asyncio.sleep(0.01)
64
+ ticks += 1
65
+
66
+ beat = asyncio.create_task(heartbeat())
67
+ await query_ddg_search("widget", 5)
68
+ stop.set()
69
+ await beat
70
+
71
+ # A responsive loop ticks ~25 times in 0.25s; a blocked one manages ~1.
72
+ assert ticks > 10, f"event loop only got {ticks} turns during a {BLOCK_SECONDS}s query"
tests/test_serp_navigation.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The glue holding each scraper together.
2
+
3
+ Every Playwright scraper is now: build a URL, open a page, block
4
+ decorative resources, navigate, extract. The builders are tested in
5
+ test_serp_urls.py and the extractors in test_serp_playwright.py; this
6
+ covers the composition - specifically that each scraper navigates to the
7
+ URL its own builder produced.
8
+
9
+ That link is the one the old goto-patching harness could not test, because
10
+ the stand-in it installed accepted the navigation URL and discarded it.
11
+ These use a recording stand-in browser instead: no real Chromium, and the
12
+ URL is the thing being asserted on.
13
+ """
14
+
15
+ import pytest
16
+
17
+ import serp
18
+ from serp import (bing_search_url, brave_search_url, google_patents_search_url,
19
+ google_scholar_url, playwright_open_page,
20
+ query_bing_search, query_brave_search, query_google_patents,
21
+ query_google_scholar)
22
+
23
+ SENTINEL = [{"title": "extracted"}]
24
+
25
+
26
+ class _RecordingPage:
27
+ def __init__(self):
28
+ self.goto_urls = []
29
+ self.route_patterns = []
30
+ self.closed = False
31
+
32
+ async def route(self, pattern, handler):
33
+ self.route_patterns.append(pattern)
34
+
35
+ async def goto(self, url, **kwargs):
36
+ self.goto_urls.append(url)
37
+ return None
38
+
39
+ async def close(self):
40
+ self.closed = True
41
+
42
+
43
+ class _RecordingContext:
44
+ def __init__(self, page):
45
+ self._page = page
46
+ self.closed = False
47
+
48
+ async def new_page(self):
49
+ return self._page
50
+
51
+ async def close(self):
52
+ self.closed = True
53
+
54
+
55
+ class _RecordingBrowser:
56
+ def __init__(self):
57
+ self.page = _RecordingPage()
58
+ self.context = _RecordingContext(self.page)
59
+
60
+ async def new_context(self, **kwargs):
61
+ return self.context
62
+
63
+
64
+ async def _sentinel_extractor(page, n_results):
65
+ return SENTINEL
66
+
67
+
68
+ SCRAPERS = [
69
+ ("query_google_scholar", query_google_scholar,
70
+ "_extract_google_scholar_results", google_scholar_url),
71
+ ("query_google_patents", query_google_patents,
72
+ "_extract_google_patents_results", google_patents_search_url),
73
+ ("query_brave_search", query_brave_search,
74
+ "_extract_brave_results", brave_search_url),
75
+ ("query_bing_search", query_bing_search,
76
+ "_extract_bing_results", bing_search_url),
77
+ ]
78
+
79
+
80
+ @pytest.mark.parametrize("name, scraper, extractor_attr, builder",
81
+ SCRAPERS, ids=[s[0] for s in SCRAPERS])
82
+ async def test_scraper_navigates_to_the_url_its_builder_produced(
83
+ monkeypatch, name, scraper, extractor_attr, builder):
84
+ monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
85
+ browser = _RecordingBrowser()
86
+
87
+ await scraper(browser, "agentic ai", 25)
88
+
89
+ assert browser.page.goto_urls == [builder("agentic ai", 25)]
90
+
91
+
92
+ @pytest.mark.parametrize("name, scraper, extractor_attr, builder",
93
+ SCRAPERS, ids=[s[0] for s in SCRAPERS])
94
+ async def test_scraper_returns_what_its_extractor_produced(
95
+ monkeypatch, name, scraper, extractor_attr, builder):
96
+ monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
97
+
98
+ results = await scraper(_RecordingBrowser(), "widgets", 10)
99
+
100
+ assert results == SENTINEL
101
+
102
+
103
+ @pytest.mark.parametrize("name, scraper, extractor_attr, builder",
104
+ SCRAPERS, ids=[s[0] for s in SCRAPERS])
105
+ async def test_scraper_blocks_decorative_resources(
106
+ monkeypatch, name, scraper, extractor_attr, builder):
107
+ """Skipping stylesheets and images meaningfully speeds up navigation,
108
+ and every scraper is supposed to do it."""
109
+ monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
110
+ browser = _RecordingBrowser()
111
+
112
+ await scraper(browser, "widgets", 10)
113
+
114
+ assert browser.page.route_patterns == ["**/*"]
115
+
116
+
117
+ @pytest.mark.parametrize("name, scraper, extractor_attr, builder",
118
+ SCRAPERS, ids=[s[0] for s in SCRAPERS])
119
+ async def test_scraper_closes_its_page_and_context(
120
+ monkeypatch, name, scraper, extractor_attr, builder):
121
+ monkeypatch.setattr(serp, extractor_attr, _sentinel_extractor)
122
+ browser = _RecordingBrowser()
123
+
124
+ await scraper(browser, "widgets", 10)
125
+
126
+ assert browser.page.closed and browser.context.closed
127
+
128
+
129
+ async def test_page_and_context_are_closed_even_when_extraction_raises(monkeypatch):
130
+ async def exploding_extractor(page, n_results):
131
+ raise RuntimeError("selectors changed")
132
+
133
+ monkeypatch.setattr(serp, "_extract_bing_results", exploding_extractor)
134
+ browser = _RecordingBrowser()
135
+
136
+ with pytest.raises(RuntimeError):
137
+ await query_bing_search(browser, "widgets", 10)
138
+
139
+ assert browser.page.closed and browser.context.closed
140
+
141
+
142
+ async def test_context_is_closed_even_when_closing_the_page_raises():
143
+ """A crashed renderer can make page.close() throw; the context still has
144
+ to be released or it leaks for the process's lifetime."""
145
+ browser = _RecordingBrowser()
146
+
147
+ async def exploding_close():
148
+ raise RuntimeError("renderer gone")
149
+
150
+ browser.page.close = exploding_close
151
+
152
+ with pytest.raises(RuntimeError):
153
+ async with playwright_open_page(browser):
154
+ pass
155
+
156
+ assert browser.context.closed
tests/test_serp_playwright.py CHANGED
@@ -1,36 +1,36 @@
1
- """Characterization tests for the Playwright-driven scrapers in serp.py.
2
-
3
- These run against a real headless Chromium (same engine production uses) so
4
- that DOM/CSS-selector behavior is genuinely exercised, but the target page is
5
- served locally from a fixture instead of navigating to the live site - see
6
- `helpers.make_fixture_browser` for how, and why a simpler
7
- `page.route()`-based approach doesn't work here.
 
 
 
 
 
 
 
8
  """
9
 
10
  import pytest
11
 
12
- from helpers import load_fixture, make_fixture_browser
13
  from serp import (
14
  BraveSearchBlockedException,
15
  BrowserUnavailableError,
 
16
  _extract_bing_results,
 
 
 
17
  playwright_open_page,
18
- query_bing_search,
19
- query_brave_search,
20
- query_google_patents,
21
- query_google_scholar,
22
  )
23
 
24
 
25
- async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
26
- """Demonstrates the payoff of splitting fetch (navigate) from parse
27
- (extract): `_extract_bing_results` takes a Page that's already loaded,
28
- so this needs nothing beyond `set_content` - no goto-patching wrapper,
29
- no browser-as-a-parameter indirection.
30
- """
31
- context = await browser.new_context()
32
- page = await context.new_page()
33
- await page.set_content(load_fixture("bing_results.html"))
34
 
35
  results = await _extract_bing_results(page, 10)
36
 
@@ -39,24 +39,19 @@ async def test_extract_bing_results_works_on_an_already_loaded_page(browser):
39
  {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
40
  ]
41
 
42
- await context.close()
43
 
 
 
44
 
45
- async def test_bing_search_parses_results(browser):
46
- fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
47
 
48
- results = await query_bing_search(fixture_browser, "test query", 10)
49
-
50
- assert results == [
51
- {"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
52
- {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
53
- ]
54
 
55
 
56
- async def test_brave_search_parses_results(browser):
57
- fixture_browser = make_fixture_browser(browser, load_fixture("brave_results.html"))
58
 
59
- results = await query_brave_search(fixture_browser, "test query", 10)
60
 
61
  assert results == [
62
  {"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
@@ -64,17 +59,30 @@ async def test_brave_search_parses_results(browser):
64
  ]
65
 
66
 
67
- async def test_brave_search_detects_the_suspicious_block_page(browser):
68
- fixture_browser = make_fixture_browser(browser, load_fixture("brave_blocked.html"))
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  with pytest.raises(BraveSearchBlockedException):
71
- await query_brave_search(fixture_browser, "test query", 10)
72
 
73
 
74
- async def test_google_scholar_parses_results(browser):
75
- fixture_browser = make_fixture_browser(browser, load_fixture("scholar_results.html"))
76
 
77
- results = await query_google_scholar(fixture_browser, "test query", 10)
78
 
79
  assert results == [
80
  {"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
@@ -82,10 +90,18 @@ async def test_google_scholar_parses_results(browser):
82
  ]
83
 
84
 
85
- async def test_google_patents_parses_results(browser):
86
- fixture_browser = make_fixture_browser(browser, load_fixture("patents", "search_results.html"))
 
 
 
 
87
 
88
- results = await query_google_patents(fixture_browser, "test query", 10)
 
 
 
 
89
 
90
  assert results == [
91
  {
@@ -103,7 +119,57 @@ async def test_google_patents_parses_results(browser):
103
  ]
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
106
  async def test_playwright_open_page_raises_when_browser_unavailable():
107
  with pytest.raises(BrowserUnavailableError):
108
  async with playwright_open_page(None):
109
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the DOM-extraction half of the Playwright-driven scrapers.
2
+
3
+ Each scraper in serp.py is three separable pieces: a URL builder (pure,
4
+ tested in test_serp_urls.py), a navigation step, and an `_extract_*`
5
+ function that reads an already-loaded page. These test the third piece
6
+ against a real headless Chromium - the same engine production uses, so
7
+ selector behaviour is genuinely exercised - with the fixture HTML loaded
8
+ via `set_content`.
9
+
10
+ This used to require a wrapper that replaced `page.goto` on a
11
+ purpose-built fake Browser, because fetch and parse weren't separated.
12
+ That wrapper accepted the navigation URL and discarded it, which is
13
+ precisely why URL bugs were invisible to the suite; splitting the scrapers
14
+ made it unnecessary and it has been deleted.
15
  """
16
 
17
  import pytest
18
 
19
+ from helpers import load_fixture
20
  from serp import (
21
  BraveSearchBlockedException,
22
  BrowserUnavailableError,
23
+ _block_stylesheet_and_image_resources,
24
  _extract_bing_results,
25
+ _extract_brave_results,
26
+ _extract_google_patents_results,
27
+ _extract_google_scholar_results,
28
  playwright_open_page,
 
 
 
 
29
  )
30
 
31
 
32
+ async def test_bing_results_are_extracted(page_factory):
33
+ page = await page_factory(load_fixture("bing_results.html"))
 
 
 
 
 
 
 
34
 
35
  results = await _extract_bing_results(page, 10)
36
 
 
39
  {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
40
  ]
41
 
 
42
 
43
+ async def test_bing_extraction_honours_the_result_limit(page_factory):
44
+ page = await page_factory(load_fixture("bing_results.html"))
45
 
46
+ results = await _extract_bing_results(page, 1)
 
47
 
48
+ assert [r["title"] for r in results] == ["Result A"]
 
 
 
 
 
49
 
50
 
51
+ async def test_brave_results_are_extracted(page_factory):
52
+ page = await page_factory(load_fixture("brave_results.html"))
53
 
54
+ results = await _extract_brave_results(page, 10)
55
 
56
  assert results == [
57
  {"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
 
59
  ]
60
 
61
 
62
+ async def test_brave_extraction_honours_the_result_limit(page_factory):
63
+ page = await page_factory(load_fixture("brave_results.html"))
64
+
65
+ results = await _extract_brave_results(page, 1)
66
+
67
+ assert [r["title"] for r in results] == ["Brave Result A"]
68
+
69
+
70
+ async def test_brave_block_page_raises(page_factory):
71
+ """Brave's anti-bot interstitial has no result cards, so it would
72
+ otherwise look like a legitimate zero-result search - and the
73
+ `/serp/search` fallback chain needs an exception to move to the next
74
+ backend.
75
+ """
76
+ page = await page_factory(load_fixture("brave_blocked.html"))
77
 
78
  with pytest.raises(BraveSearchBlockedException):
79
+ await _extract_brave_results(page, 10)
80
 
81
 
82
+ async def test_google_scholar_results_are_extracted(page_factory):
83
+ page = await page_factory(load_fixture("scholar_results.html"))
84
 
85
+ results = await _extract_google_scholar_results(page, 10)
86
 
87
  assert results == [
88
  {"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
 
90
  ]
91
 
92
 
93
+ async def test_google_scholar_extraction_honours_the_result_limit(page_factory):
94
+ page = await page_factory(load_fixture("scholar_results.html"))
95
+
96
+ results = await _extract_google_scholar_results(page, 1)
97
+
98
+ assert [r["title"] for r in results] == ["Scholar Paper A"]
99
 
100
+
101
+ async def test_google_patents_results_are_extracted(page_factory):
102
+ page = await page_factory(load_fixture("patents", "search_results.html"))
103
+
104
+ results = await _extract_google_patents_results(page, 10)
105
 
106
  assert results == [
107
  {
 
119
  ]
120
 
121
 
122
+ async def test_google_patents_extraction_honours_the_result_limit(page_factory):
123
+ page = await page_factory(load_fixture("patents", "search_results.html"))
124
+
125
+ results = await _extract_google_patents_results(page, 1)
126
+
127
+ assert [r["id"] for r in results] == ["US11930446B2"]
128
+
129
+
130
+ # --------------------------------- shared plumbing ---------------------------------
131
+
132
+
133
  async def test_playwright_open_page_raises_when_browser_unavailable():
134
  with pytest.raises(BrowserUnavailableError):
135
  async with playwright_open_page(None):
136
  pass
137
+
138
+
139
+ class _FakeRoute:
140
+ def __init__(self):
141
+ self.aborted = False
142
+ self.continued = False
143
+
144
+ async def abort(self):
145
+ self.aborted = True
146
+
147
+ async def continue_(self):
148
+ self.continued = True
149
+
150
+
151
+ class _FakeRequest:
152
+ def __init__(self, resource_type):
153
+ self.resource_type = resource_type
154
+
155
+
156
+ @pytest.mark.parametrize("resource_type", ["stylesheet", "image"])
157
+ async def test_decorative_resources_are_blocked(resource_type):
158
+ """Only page text matters to these scrapers, and skipping stylesheets
159
+ and images meaningfully speeds up navigation."""
160
+ route = _FakeRoute()
161
+
162
+ await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
163
+
164
+ assert route.aborted and not route.continued
165
+
166
+
167
+ @pytest.mark.parametrize("resource_type", ["document", "script", "xhr", "fetch"])
168
+ async def test_everything_else_is_allowed_through(resource_type):
169
+ """Blocking the main document (or the XHR that renders results) would
170
+ break the scrape entirely."""
171
+ route = _FakeRoute()
172
+
173
+ await _block_stylesheet_and_image_resources(route, _FakeRequest(resource_type))
174
+
175
+ assert route.continued and not route.aborted
tests/test_serp_urls.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The search URLs each scraper navigates to.
2
+
3
+ These had no coverage at all, and the gap was invisible: the Playwright
4
+ tests drive a real browser but patch `page.goto` with a stand-in that
5
+ accepts the URL and discards it, so everything downstream of navigation
6
+ was verified and everything upstream was not. Mutation testing confirmed
7
+ it - replacing the Bing URL with a constant, dropping `&num=` from the
8
+ Scholar URL, and making the Google Patents URL ignore the query all left
9
+ the full suite green. A scraper that searched for the wrong thing entirely
10
+ passed.
11
+
12
+ These are pure string assertions: no browser, no fixtures, microseconds.
13
+ """
14
+
15
+ import pytest
16
+
17
+ from serp import (
18
+ bing_search_url,
19
+ brave_search_url,
20
+ google_patents_search_url,
21
+ google_scholar_url,
22
+ )
23
+
24
+
25
+ class TestQueryIsIncluded:
26
+ """The single most important property: the URL must ask for what the
27
+ caller asked for."""
28
+
29
+ @pytest.mark.parametrize("builder", [
30
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
31
+ def test_the_query_reaches_the_url(self, builder):
32
+ assert "widgets" in builder("widgets", 10)
33
+
34
+ @pytest.mark.parametrize("builder", [
35
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
36
+ def test_a_different_query_produces_a_different_url(self, builder):
37
+ assert builder("widgets", 10) != builder("gadgets", 10)
38
+
39
+
40
+ class TestQueryIsEncoded:
41
+ """A raw query in a URL is both a correctness bug (spaces, ampersands
42
+ and hashes truncate or corrupt the search) and the kind of thing that
43
+ turns user input into URL structure."""
44
+
45
+ @pytest.mark.parametrize("builder", [
46
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
47
+ def test_spaces_are_encoded(self, builder):
48
+ url = builder("agentic ai", 10)
49
+ assert "agentic+ai" in url or "agentic%20ai" in url
50
+ assert "agentic ai" not in url
51
+
52
+ @pytest.mark.parametrize("builder", [
53
+ bing_search_url, brave_search_url, google_patents_search_url, google_scholar_url])
54
+ def test_url_significant_characters_are_escaped(self, builder):
55
+ url = builder("a&b=c#d", 10)
56
+ query_part = url.split("?", 1)[1]
57
+ # Exactly one separator per parameter the builder itself added.
58
+ assert "a&b=c#d" not in query_part
59
+ assert "%26" in query_part
60
+
61
+
62
+ class TestResultCount:
63
+ def test_scholar_asks_for_the_requested_number_of_results(self):
64
+ assert "num=25" in google_scholar_url("widgets", 25)
65
+
66
+ def test_google_patents_asks_for_the_requested_number_of_results(self):
67
+ assert "num=25" in google_patents_search_url("widgets", 25)
68
+
69
+
70
+ class TestHosts:
71
+ @pytest.mark.parametrize("builder, host", [
72
+ (bing_search_url, "https://www.bing.com/search"),
73
+ (brave_search_url, "https://search.brave.com/search"),
74
+ (google_patents_search_url, "https://patents.google.com/"),
75
+ (google_scholar_url, "https://scholar.google.com/scholar"),
76
+ ])
77
+ def test_points_at_the_expected_endpoint(self, builder, host):
78
+ assert builder("widgets", 10).startswith(host)
tests/test_utils.py CHANGED
@@ -1,15 +1,13 @@
1
  import logging
2
 
3
- from serp import SerpQuery
4
  from utils import log_gathered_exceptions
5
 
6
 
7
  def test_logs_only_the_exceptions_with_their_query(caplog):
8
- params = SerpQuery(queries=["q1", "q2", "q3"])
9
  results = ["ok result", ValueError("boom"), "another ok"]
10
 
11
  with caplog.at_level(logging.WARNING):
12
- log_gathered_exceptions(results, "test context", params)
13
 
14
  warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
15
  assert len(warnings) == 1
@@ -19,11 +17,8 @@ def test_logs_only_the_exceptions_with_their_query(caplog):
19
 
20
 
21
  def test_logs_nothing_when_all_results_succeeded(caplog):
22
- params = SerpQuery(queries=["q1", "q2"])
23
- results = ["ok", "also ok"]
24
-
25
  with caplog.at_level(logging.WARNING):
26
- log_gathered_exceptions(results, "test context", params)
27
 
28
  assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
29
 
@@ -33,8 +28,29 @@ def test_uses_its_own_logger_not_the_asyncio_one(caplog):
33
  misattributed these warnings to the 'asyncio' logging namespace instead
34
  of the app's own. Fixed to `logging.getLogger(__name__)`.
35
  """
36
- params = SerpQuery(queries=["q1"])
37
  with caplog.at_level(logging.WARNING):
38
- log_gathered_exceptions([ValueError("boom")], "ctx", params)
39
 
40
  assert caplog.records[0].name == "utils"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
 
 
3
  from utils import log_gathered_exceptions
4
 
5
 
6
  def test_logs_only_the_exceptions_with_their_query(caplog):
 
7
  results = ["ok result", ValueError("boom"), "another ok"]
8
 
9
  with caplog.at_level(logging.WARNING):
10
+ log_gathered_exceptions(results, "test context", ["q1", "q2", "q3"])
11
 
12
  warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
13
  assert len(warnings) == 1
 
17
 
18
 
19
  def test_logs_nothing_when_all_results_succeeded(caplog):
 
 
 
20
  with caplog.at_level(logging.WARNING):
21
+ log_gathered_exceptions(["ok", "also ok"], "test context", ["q1", "q2"])
22
 
23
  assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
24
 
 
28
  misattributed these warnings to the 'asyncio' logging namespace instead
29
  of the app's own. Fixed to `logging.getLogger(__name__)`.
30
  """
 
31
  with caplog.at_level(logging.WARNING):
32
+ log_gathered_exceptions([ValueError("boom")], "ctx", ["q1"])
33
 
34
  assert caplog.records[0].name == "utils"
35
+
36
+
37
+ def test_takes_a_plain_sequence_of_queries(caplog):
38
+ """This helper is the lowest layer in the codebase; it used to import
39
+ SerpQuery from serp.py purely to read one attribute, inverting the
40
+ dependency. It now accepts any iterable of query strings, so the patent
41
+ and OPS paths can use it too.
42
+ """
43
+ with caplog.at_level(logging.WARNING):
44
+ log_gathered_exceptions([ValueError("boom")], "ctx", iter(["only-query"]))
45
+
46
+ assert "only-query" in caplog.records[0].message
47
+
48
+
49
+ def test_extra_results_without_a_matching_query_are_ignored(caplog):
50
+ """zip stops at the shorter sequence - pinning that so a mismatch can't
51
+ raise inside an error-logging path.
52
+ """
53
+ with caplog.at_level(logging.WARNING):
54
+ log_gathered_exceptions([ValueError("a"), ValueError("b")], "ctx", ["q1"])
55
+
56
+ assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1
utils.py CHANGED
@@ -1,12 +1,20 @@
1
  import logging
2
-
3
- from serp import SerpQuery
4
 
5
  logger = logging.getLogger(__name__)
6
 
7
 
8
- def log_gathered_exceptions(results: list, context: str, params: SerpQuery):
9
- """Logs gathered exceptions with context and parameters."""
10
- for exc, q in zip(results, params.queries):
 
 
 
 
 
 
 
 
 
11
  if isinstance(exc, Exception):
12
  logger.warning(f"Error during {context} for query '{q}': {exc}")
 
1
  import logging
2
+ from typing import Iterable
 
3
 
4
  logger = logging.getLogger(__name__)
5
 
6
 
7
+ def log_gathered_exceptions(results: list, context: str, queries: Iterable[str]):
8
+ """Log the exceptions in a `return_exceptions=True` gather, named by the
9
+ query that produced each.
10
+
11
+ Takes the queries themselves rather than a SerpQuery: this is the
12
+ lowest layer in the codebase and importing the domain model inverted
13
+ the dependency for the sake of one attribute access. It also means the
14
+ patent and OPS paths can use it.
15
+ """
16
+ # strict=False deliberately: this is an error-logging path and must
17
+ # never raise on a length mismatch of its own.
18
+ for exc, q in zip(results, queries, strict=False):
19
  if isinstance(exc, Exception):
20
  logger.warning(f"Error during {context} for query '{q}': {exc}")