Spaces:
Running
Deduplicate app.py's search endpoints (item 4 from review)
Browse filesSix of the seven /serp and /ops search endpoints repeated the identical
gather -> log -> filter -> flatten -> shape-a-SerpResults pattern verbatim,
differing only in which query_* function they called. Extracted into two
helpers:
- _shape_serp_results(results): flatten a gather's per-query results (or
Exceptions), surfacing the last error only when every query failed.
- _run_serp_queries(fn, params, context): run fn concurrently across every
query, log failures, and shape via _shape_serp_results.
search_patents keeps its distinct OPS-fallback loop (genuinely different
logic, not boilerplate) but now reuses _shape_serp_results for its tail.
app.py had zero test coverage before this - added tests/test_app.py first
(characterizing the endpoints' current behavior via ASGITransport, with
the query_*/ops_search functions monkeypatched rather than hitting real
search engines or needing a real Playwright browser) and confirmed those
pass against the pre-refactor code, then did the extraction under that
safety net. All 80 tests pass; app.py is 385 -> 339 lines.
- app.py +35 -80
- tests/test_app.py +159 -0
|
@@ -104,40 +104,46 @@ ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"])
|
|
| 104 |
# ===================== Search endpoints =====================
|
| 105 |
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
log_gathered_exceptions(results, "google scholar search", params)
|
| 113 |
-
|
| 114 |
-
# Filter out exceptions and flatten the results
|
| 115 |
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 116 |
flattened_results = [
|
| 117 |
item for sublist in filtered_results for item in sublist]
|
| 118 |
|
| 119 |
-
# all queries failed, return the last exception
|
| 120 |
if len(filtered_results) == 0:
|
| 121 |
return SerpResults(results=[], error=str(results[-1]))
|
| 122 |
|
| 123 |
return SerpResults(results=flattened_results, error=None)
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
@serp_router.post("/search_arxiv")
|
| 127 |
async def search_arxiv(params: SerpQuery):
|
| 128 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 129 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 134 |
-
flattened_results = [
|
| 135 |
-
item for sublist in filtered_results for item in sublist]
|
| 136 |
-
|
| 137 |
-
if len(filtered_results) == 0:
|
| 138 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 139 |
-
|
| 140 |
-
return SerpResults(results=flattened_results, error=None)
|
| 141 |
|
| 142 |
|
| 143 |
@serp_router.post("/search_patents")
|
|
@@ -162,73 +168,31 @@ async def search_patents(params: SerpQuery) -> SerpResults:
|
|
| 162 |
except Exception as e:
|
| 163 |
logging.warning(f"OPS fallback failed: {e}")
|
| 164 |
|
| 165 |
-
|
| 166 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 167 |
-
flattened_results = [
|
| 168 |
-
item for sublist in filtered_results for item in sublist]
|
| 169 |
-
|
| 170 |
-
# all queries failed, return the last exception
|
| 171 |
-
if len(filtered_results) == 0:
|
| 172 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 173 |
-
|
| 174 |
-
return SerpResults(results=flattened_results, error=None)
|
| 175 |
|
| 176 |
|
| 177 |
@serp_router.post("/search_brave")
|
| 178 |
async def search_brave(params: SerpQuery) -> SerpResults:
|
| 179 |
"""Searches brave search for the specified queries and returns the found documents."""
|
| 180 |
logging.info(f"Searching Brave Search for queries: {params.queries}")
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
# Filter out exceptions and flatten the results
|
| 185 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 186 |
-
flattened_results = [
|
| 187 |
-
item for sublist in filtered_results for item in sublist]
|
| 188 |
-
|
| 189 |
-
# all queries failed, return the last exception
|
| 190 |
-
if len(filtered_results) == 0:
|
| 191 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 192 |
-
|
| 193 |
-
return SerpResults(results=flattened_results, error=None)
|
| 194 |
|
| 195 |
|
| 196 |
@serp_router.post("/search_bing")
|
| 197 |
async def search_bing(params: SerpQuery) -> SerpResults:
|
| 198 |
"""Searches Bing search for the specified queries and returns the found documents."""
|
| 199 |
logging.info(f"Searching Bing Search for queries: {params.queries}")
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
# Filter out exceptions and flatten the results
|
| 204 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 205 |
-
flattened_results = [
|
| 206 |
-
item for sublist in filtered_results for item in sublist]
|
| 207 |
-
|
| 208 |
-
# all queries failed, return the last exception
|
| 209 |
-
if len(filtered_results) == 0:
|
| 210 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 211 |
-
|
| 212 |
-
return SerpResults(results=flattened_results, error=None)
|
| 213 |
|
| 214 |
|
| 215 |
@serp_router.post("/search_duck")
|
| 216 |
async def search_duck(params: SerpQuery) -> SerpResults:
|
| 217 |
"""Searches duckduckgo for the specified queries and returns the found documents"""
|
| 218 |
logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
# Filter out exceptions and flatten the results
|
| 223 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 224 |
-
flattened_results = [
|
| 225 |
-
item for sublist in filtered_results for item in sublist]
|
| 226 |
-
|
| 227 |
-
# all queries failed, return the last exception
|
| 228 |
-
if len(filtered_results) == 0:
|
| 229 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 230 |
-
|
| 231 |
-
return SerpResults(results=flattened_results, error=None)
|
| 232 |
|
| 233 |
|
| 234 |
async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
|
@@ -327,17 +291,8 @@ async def scrap_patents(params: ScrapPatentsRequest) -> PatentScrapBulkResponse:
|
|
| 327 |
async def ops_keyword_search(params: SerpQuery) -> SerpResults:
|
| 328 |
"""Keyword-searches patents via the official EPO OPS API."""
|
| 329 |
logging.info(f"Searching EPO OPS for queries: {params.queries}")
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 334 |
-
flattened_results = [
|
| 335 |
-
item for sublist in filtered_results for item in sublist]
|
| 336 |
-
|
| 337 |
-
if len(filtered_results) == 0:
|
| 338 |
-
return SerpResults(results=[], error=str(results[-1]))
|
| 339 |
-
|
| 340 |
-
return SerpResults(results=flattened_results, error=None)
|
| 341 |
|
| 342 |
|
| 343 |
@ops_router.get("/scrap_patent/{patent_id}")
|
|
|
|
| 104 |
# ===================== Search endpoints =====================
|
| 105 |
|
| 106 |
|
| 107 |
+
def _shape_serp_results(results: list) -> SerpResults:
|
| 108 |
+
"""Flatten a list of per-query result-lists (or Exceptions, from a
|
| 109 |
+
`return_exceptions=True` gather) into one SerpResults, surfacing the
|
| 110 |
+
last error only when every query failed.
|
| 111 |
+
"""
|
|
|
|
|
|
|
|
|
|
| 112 |
filtered_results = [r for r in results if not isinstance(r, Exception)]
|
| 113 |
flattened_results = [
|
| 114 |
item for sublist in filtered_results for item in sublist]
|
| 115 |
|
|
|
|
| 116 |
if len(filtered_results) == 0:
|
| 117 |
return SerpResults(results=[], error=str(results[-1]))
|
| 118 |
|
| 119 |
return SerpResults(results=flattened_results, error=None)
|
| 120 |
|
| 121 |
|
| 122 |
+
async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
|
| 123 |
+
"""Run `fn(query, n_results)` concurrently for every query in `params`,
|
| 124 |
+
log any failures, and shape the result the way every simple /serp and
|
| 125 |
+
/ops search endpoint below expects.
|
| 126 |
+
"""
|
| 127 |
+
results = await asyncio.gather(
|
| 128 |
+
*[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
|
| 129 |
+
log_gathered_exceptions(results, context, params)
|
| 130 |
+
return _shape_serp_results(results)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@serp_router.post("/search_scholar")
|
| 134 |
+
async def search_google_scholar(params: SerpQuery):
|
| 135 |
+
"""Queries google scholar for the specified query"""
|
| 136 |
+
logging.info(f"Searching Google Scholar for queries: {params.queries}")
|
| 137 |
+
return await _run_serp_queries(
|
| 138 |
+
lambda q, n: query_google_scholar(pw_browser, q, n), params, "google scholar search")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
@serp_router.post("/search_arxiv")
|
| 142 |
async def search_arxiv(params: SerpQuery):
|
| 143 |
"""Searches arxiv for the specified queries and returns the found documents."""
|
| 144 |
logging.info(f"Searching Arxiv for queries: {params.queries}")
|
| 145 |
+
return await _run_serp_queries(
|
| 146 |
+
lambda q, n: query_arxiv(httpx_client, q, n), params, "arxiv search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
|
| 149 |
@serp_router.post("/search_patents")
|
|
|
|
| 168 |
except Exception as e:
|
| 169 |
logging.warning(f"OPS fallback failed: {e}")
|
| 170 |
|
| 171 |
+
return _shape_serp_results(results)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
@serp_router.post("/search_brave")
|
| 175 |
async def search_brave(params: SerpQuery) -> SerpResults:
|
| 176 |
"""Searches brave search for the specified queries and returns the found documents."""
|
| 177 |
logging.info(f"Searching Brave Search for queries: {params.queries}")
|
| 178 |
+
return await _run_serp_queries(
|
| 179 |
+
lambda q, n: query_brave_search(pw_browser, q, n), params, "brave search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
@serp_router.post("/search_bing")
|
| 183 |
async def search_bing(params: SerpQuery) -> SerpResults:
|
| 184 |
"""Searches Bing search for the specified queries and returns the found documents."""
|
| 185 |
logging.info(f"Searching Bing Search for queries: {params.queries}")
|
| 186 |
+
return await _run_serp_queries(
|
| 187 |
+
lambda q, n: query_bing_search(pw_browser, q, n), params, "bing search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
|
| 190 |
@serp_router.post("/search_duck")
|
| 191 |
async def search_duck(params: SerpQuery) -> SerpResults:
|
| 192 |
"""Searches duckduckgo for the specified queries and returns the found documents"""
|
| 193 |
logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
|
| 194 |
+
return await _run_serp_queries(
|
| 195 |
+
lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
|
| 198 |
async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
|
|
|
| 291 |
async def ops_keyword_search(params: SerpQuery) -> SerpResults:
|
| 292 |
"""Keyword-searches patents via the official EPO OPS API."""
|
| 293 |
logging.info(f"Searching EPO OPS for queries: {params.queries}")
|
| 294 |
+
return await _run_serp_queries(
|
| 295 |
+
lambda q, n: ops_search(httpx_client, q, n), params, "OPS search")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
|
| 298 |
@ops_router.get("/scrap_patent/{patent_id}")
|
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 13 |
+
import pytest
|
| 14 |
+
from httpx import ASGITransport
|
| 15 |
+
|
| 16 |
+
import app as app_module
|
| 17 |
+
from serp import SerpQuery
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
async def client():
|
| 22 |
+
transport = ASGITransport(app=app_module.app)
|
| 23 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
| 24 |
+
yield c
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ------------------------- _shape_serp_results / _run_serp_queries -------------------------
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_shape_serp_results_flattens_successful_lists():
|
| 31 |
+
result = app_module._shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
|
| 32 |
+
assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
|
| 33 |
+
assert result.error is None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_shape_serp_results_surfaces_last_error_when_all_failed():
|
| 37 |
+
result = app_module._shape_serp_results([ValueError("first"), RuntimeError("second")])
|
| 38 |
+
assert result.results == []
|
| 39 |
+
assert "second" in result.error
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_shape_serp_results_ignores_failures_when_some_succeed():
|
| 43 |
+
result = app_module._shape_serp_results([[{"a": 1}], RuntimeError("boom")])
|
| 44 |
+
assert result.results == [{"a": 1}]
|
| 45 |
+
assert result.error is None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def test_run_serp_queries_calls_fn_per_query_and_shapes_result():
|
| 49 |
+
params = SerpQuery(queries=["x", "y"], n_results=5)
|
| 50 |
+
calls = []
|
| 51 |
+
|
| 52 |
+
async def fn(q, n):
|
| 53 |
+
calls.append((q, n))
|
| 54 |
+
return [{"q": q}]
|
| 55 |
+
|
| 56 |
+
result = await app_module._run_serp_queries(fn, params, "test context")
|
| 57 |
+
|
| 58 |
+
assert sorted(calls) == [("x", 5), ("y", 5)]
|
| 59 |
+
assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ------------------------------------- endpoints -------------------------------------
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def test_search_arxiv_flattens_results_from_all_queries(client, monkeypatch):
|
| 66 |
+
async def fake_query_arxiv(client_arg, q, n):
|
| 67 |
+
return [{"title": f"paper about {q}", "href": "x", "body": "y", "id": "1"}]
|
| 68 |
+
|
| 69 |
+
monkeypatch.setattr(app_module, "query_arxiv", fake_query_arxiv)
|
| 70 |
+
|
| 71 |
+
resp = await client.post("/serp/search_arxiv", json={"queries": ["a", "b"], "n_results": 5})
|
| 72 |
+
|
| 73 |
+
assert resp.status_code == 200
|
| 74 |
+
data = resp.json()
|
| 75 |
+
assert data["error"] is None
|
| 76 |
+
assert {r["title"] for r in data["results"]} == {"paper about a", "paper about b"}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def test_search_arxiv_all_queries_failing_returns_error(client, monkeypatch):
|
| 80 |
+
async def failing_query_arxiv(client_arg, q, n):
|
| 81 |
+
raise RuntimeError(f"boom for {q}")
|
| 82 |
+
|
| 83 |
+
monkeypatch.setattr(app_module, "query_arxiv", failing_query_arxiv)
|
| 84 |
+
|
| 85 |
+
resp = await client.post("/serp/search_arxiv", json={"queries": ["a"], "n_results": 5})
|
| 86 |
+
|
| 87 |
+
assert resp.status_code == 200
|
| 88 |
+
data = resp.json()
|
| 89 |
+
assert data["results"] == []
|
| 90 |
+
assert "boom for a" in data["error"]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def test_search_patents_does_not_fall_back_when_google_patents_succeeds(client, monkeypatch):
|
| 94 |
+
async def fake_google_patents(browser, q, n):
|
| 95 |
+
return [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
|
| 96 |
+
|
| 97 |
+
ops_search_called = False
|
| 98 |
+
|
| 99 |
+
async def fake_ops_search(client_arg, q, n):
|
| 100 |
+
nonlocal ops_search_called
|
| 101 |
+
ops_search_called = True
|
| 102 |
+
return []
|
| 103 |
+
|
| 104 |
+
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 105 |
+
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 106 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
|
| 107 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
|
| 108 |
+
|
| 109 |
+
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 110 |
+
|
| 111 |
+
data = resp.json()
|
| 112 |
+
assert data["results"] == [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
|
| 113 |
+
assert ops_search_called is False
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def test_search_patents_falls_back_to_ops_when_google_patents_empty(client, monkeypatch):
|
| 117 |
+
async def fake_google_patents(browser, q, n):
|
| 118 |
+
return []
|
| 119 |
+
|
| 120 |
+
async def fake_ops_search(client_arg, q, n):
|
| 121 |
+
return [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
|
| 122 |
+
|
| 123 |
+
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 124 |
+
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 125 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
|
| 126 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
|
| 127 |
+
|
| 128 |
+
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 129 |
+
|
| 130 |
+
data = resp.json()
|
| 131 |
+
assert data["results"] == [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def test_search_patents_no_fallback_when_ops_not_configured(client, monkeypatch):
|
| 135 |
+
async def fake_google_patents(browser, q, n):
|
| 136 |
+
return []
|
| 137 |
+
|
| 138 |
+
monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
|
| 139 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", None)
|
| 140 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", None)
|
| 141 |
+
|
| 142 |
+
resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
|
| 143 |
+
|
| 144 |
+
data = resp.json()
|
| 145 |
+
assert data["results"] == []
|
| 146 |
+
assert data["error"] is None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
async def test_ops_keyword_search_flattens_results(client, monkeypatch):
|
| 150 |
+
async def fake_ops_search(client_arg, q, n):
|
| 151 |
+
return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
|
| 152 |
+
|
| 153 |
+
monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
|
| 154 |
+
|
| 155 |
+
resp = await client.post("/ops/search", json={"queries": ["a", "b"], "n_results": 5})
|
| 156 |
+
|
| 157 |
+
data = resp.json()
|
| 158 |
+
assert data["error"] is None
|
| 159 |
+
assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
|