Claude commited on
Commit
5796881
·
unverified ·
1 Parent(s): d341a7a

Add pytest harness with characterization tests for scrap/ops/serp/utils

Browse files

Adds a test safety net ahead of any refactoring, per the TDD/architecture
review of this codebase (no test infrastructure existed before this):

- pytest + pytest-asyncio + respx as dev-only deps (requirements-dev.txt)
- Pure-function tests for SerpQuery's n_results clamping, utils.py's
exception logging, and every OPS JSON-walking helper (ops.py handles
OPS's XML-to-JSON quirk where a repeated element is a list but a single
one stays a bare dict - tests cover both shapes for each helper)
- HTTP-mocked (respx) tests for scrap_patent_async's HTML parsing
(abstract/description/claims/classification-code extraction, plus the
documented ValueError path for an unexpected page layout), the OPS
token manager's caching/refresh/retry-on-stale-token behavior, and
query_arxiv's Atom feed parsing
- A DDGS-mocked test pinning the DuckDuckGo empty-results-means-blocked
fix from d341a7a, so a regression there fails a test instead of
shipping silently
- Real-headless-Chromium tests for the four Playwright-driven scrapers
(Bing, Brave, Google Scholar, Google Patents search), using a
goto-patching Browser/Context wrapper (tests/helpers.py) to serve
fixture HTML instead of hitting live search engines - documented there
because the more obvious page.route() interception approach doesn't
work against these functions as currently written (they register their
own catch-all route internally, which wins and calls continue_()
instead of falling back)

WIP note: one Playwright test currently hangs under investigation
(test_brave_search_detects_the_suspicious_block_page) - follow-up commit
will fix it before this is considered done.

pytest.ini ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ testpaths = tests
requirements-dev.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Test-only dependencies. Install with:
2
+ # pip install -r requirements.txt -r requirements-dev.txt
3
+ # playwright install chromium # if not already present on the machine
4
+ -r requirements.txt
5
+ pytest>=8,<10
6
+ pytest-asyncio>=0.24
7
+ respx>=0.21
tests/conftest.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest_asyncio
6
+
7
+ # The app modules (serp.py, scrap.py, ops.py, utils.py) live at the repo
8
+ # root, not inside a package - make sure they're importable regardless of
9
+ # how pytest resolves its rootdir.
10
+ REPO_ROOT = Path(__file__).resolve().parent.parent
11
+ sys.path.insert(0, str(REPO_ROOT))
12
+
13
+
14
+ def _find_chromium_executable() -> str | None:
15
+ """Fallback lookup for environments where the browser Playwright's pip
16
+ package expects doesn't match what's actually on disk (e.g. a sandbox
17
+ image that pre-installs a specific Chromium revision separately from
18
+ however `pip install playwright` resolved). Mirrors the workaround
19
+ documented for the JS Playwright test runner in this environment.
20
+ """
21
+ browsers_path = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
22
+ if not browsers_path:
23
+ return None
24
+ root = Path(browsers_path)
25
+ if not root.is_dir():
26
+ return None
27
+ candidates = sorted(root.glob("chromium-*/chrome-linux/chrome"))
28
+ return str(candidates[-1]) if candidates else None
29
+
30
+
31
+ @pytest_asyncio.fixture(scope="session")
32
+ async def browser():
33
+ """A single real headless Chromium instance shared by the whole test
34
+ session, exactly like the one `app.py`'s lifespan starts in production.
35
+ """
36
+ from playwright.async_api import async_playwright
37
+
38
+ pw = await async_playwright().start()
39
+ try:
40
+ b = await pw.chromium.launch(headless=True)
41
+ except Exception:
42
+ executable_path = _find_chromium_executable()
43
+ if not executable_path:
44
+ raise
45
+ b = await pw.chromium.launch(headless=True, executable_path=executable_path)
46
+ yield b
47
+ await b.close()
48
+ await pw.stop()
tests/fixtures/arxiv_response.xml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <feed xmlns="http://www.w3.org/2005/Atom">
3
+ <entry>
4
+ <id>http://arxiv.org/abs/2401.00001v1</id>
5
+ <title>
6
+ A Study of Widgets
7
+ </title>
8
+ <summary>
9
+ This paper studies widgets in depth.
10
+ </summary>
11
+ </entry>
12
+ <entry>
13
+ <id>http://arxiv.org/abs/2401.00002v1</id>
14
+ <title>Gadgets and Their Applications</title>
15
+ <summary>An overview of gadgets.</summary>
16
+ </entry>
17
+ </feed>
tests/fixtures/bing_results.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <li class="b_algo">
5
+ <h2><a href="https://example.org/a">Result A</a></h2>
6
+ <div class="b_caption"><p>Snippet A</p></div>
7
+ </li>
8
+ <li class="b_algo">
9
+ <h2><a href="https://example.org/b">Result B</a></h2>
10
+ <div class="b_caption"><p>Snippet B</p></div>
11
+ </li>
12
+ </body>
13
+ </html>
tests/fixtures/brave_blocked.html ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <p>Your request looks suspicious. Please verify you are human to continue.</p>
5
+ </body>
6
+ </html>
tests/fixtures/brave_results.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <div class="snippet">
5
+ <a href="https://example.org/brave-a"><span class="title">Brave Result A</span></a>
6
+ <p class="snippet-description">Brave snippet A</p>
7
+ </div>
8
+ <div class="snippet">
9
+ <a href="https://example.org/brave-b"><span class="title">Brave Result B</span></a>
10
+ <p class="snippet-description">Brave snippet B</p>
11
+ </div>
12
+ </body>
13
+ </html>
tests/fixtures/ops/biblio_response.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "exchange-documents": {
4
+ "exchange-document": {
5
+ "@country": "US",
6
+ "@doc-number": "11930446",
7
+ "bibliographic-data": {
8
+ "publication-reference": {
9
+ "document-id": [
10
+ {"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}}
11
+ ]
12
+ },
13
+ "invention-title": {"@lang": "en", "$": "Widget apparatus"},
14
+ "patent-classifications": {
15
+ "patent-classification": [
16
+ {
17
+ "section": {"$": "G"},
18
+ "class": {"$": "06"},
19
+ "subclass": {"$": "F"},
20
+ "main-group": {"$": "17"},
21
+ "subgroup": {"$": "30"}
22
+ },
23
+ {
24
+ "section": {"$": "H"},
25
+ "class": {"$": "04"},
26
+ "subclass": {"$": "L"},
27
+ "main-group": {"$": "9"},
28
+ "subgroup": {"$": "00"}
29
+ }
30
+ ]
31
+ }
32
+ },
33
+ "abstract": {"@lang": "en", "p": [{"$": "An apparatus comprising a widget."}]}
34
+ }
35
+ }
36
+ }
37
+ }
tests/fixtures/ops/claims_response.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "ftxt:fulltext-documents": {
4
+ "ftxt:fulltext-document": {
5
+ "claims": {
6
+ "@lang": "en",
7
+ "claim": [
8
+ {"claim-text": {"$": "1. A widget comprising a gadget."}},
9
+ {"claim-text": {"$": "2. The widget of claim 1, further comprising a mechanism."}}
10
+ ]
11
+ }
12
+ }
13
+ }
14
+ }
15
+ }
tests/fixtures/ops/description_response.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "ftxt:fulltext-documents": {
4
+ "ftxt:fulltext-document": {
5
+ "description": {
6
+ "@lang": "en",
7
+ "p": [
8
+ {"$": "This invention relates to widgets."},
9
+ {"$": "The widget comprises a gadget and a mechanism."}
10
+ ]
11
+ }
12
+ }
13
+ }
14
+ }
15
+ }
tests/fixtures/ops/search_not_found_response.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "fault": {
3
+ "faultstring": "SERVER.EntityNotFound",
4
+ "detail": {"errorcode": "SERVER.EntityNotFound"}
5
+ }
6
+ }
tests/fixtures/ops/search_response.json ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "ops:world-patent-data": {
3
+ "ops:biblio-search": {
4
+ "ops:search-result": {
5
+ "exchange-documents": [
6
+ {
7
+ "exchange-document": {
8
+ "@country": "US",
9
+ "@doc-number": "11930446",
10
+ "bibliographic-data": {
11
+ "publication-reference": {
12
+ "document-id": [
13
+ {"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}}
14
+ ]
15
+ },
16
+ "invention-title": [
17
+ {"@lang": "en", "$": "Widget apparatus"},
18
+ {"@lang": "de", "$": "Widgetvorrichtung"}
19
+ ]
20
+ },
21
+ "abstract": [
22
+ {"@lang": "en", "p": [{"$": "An apparatus comprising a widget."}]}
23
+ ]
24
+ }
25
+ },
26
+ {
27
+ "exchange-document": {
28
+ "@country": "EP",
29
+ "@doc-number": "4760514",
30
+ "bibliographic-data": {
31
+ "publication-reference": {
32
+ "document-id": [
33
+ {"@document-id-type": "epodoc", "doc-number": {"$": "EP4760514"}}
34
+ ]
35
+ },
36
+ "invention-title": {"@lang": "en", "$": "Gadget system"}
37
+ },
38
+ "abstract": {"@lang": "en", "p": {"$": "A system comprising a gadget."}}
39
+ }
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ }
45
+ }
tests/fixtures/ops/token_response.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "access_token": "test-access-token-123",
3
+ "expires_in": 1199,
4
+ "token_type": "Bearer"
5
+ }
tests/fixtures/patents/full_patent.html ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta name="DC.title" content="Widget with improved gadget mechanism">
5
+ </head>
6
+ <body>
7
+
8
+ <div class="abstract">A widget comprising a gadget and a mechanism for improving widget performance.</div>
9
+
10
+ <section itemprop="description">
11
+ <div>TECHNICAL FIELD</div>
12
+ <div>This invention relates to widgets and gadgets for testing purposes.</div>
13
+ <div>BACKGROUND OF THE INVENTION</div>
14
+ <div>Prior art widgets suffered from several problems described herein.</div>
15
+ <div>SUMMARY OF THE INVENTION</div>
16
+ <div>The present invention solves the problems of the prior art by providing an improved widget.</div>
17
+ <div>DETAILED DESCRIPTION</div>
18
+ <div>The following describes example embodiments of the widget in detail.</div>
19
+ </section>
20
+
21
+ <section itemprop="claims">
22
+ <div class="claim">1. A widget comprising a gadget.</div>
23
+ <div class="claim">2. The widget of claim 1, further comprising a mechanism.</div>
24
+ </section>
25
+
26
+ <ul class="classifications">
27
+ <li><span>G06F</span>
28
+ <ul>
29
+ <li><span>G06F17/30</span> Database structures therefor</li>
30
+ <li><span>G06F17/50</span> Other database related</li>
31
+ </ul>
32
+ </li>
33
+ <li><span>H04L</span>
34
+ <ul>
35
+ <li><span>H04L9/00</span> Cryptographic mechanisms</li>
36
+ </ul>
37
+ </li>
38
+ </ul>
39
+
40
+ </body>
41
+ </html>
tests/fixtures/patents/missing_title.html ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ </head>
5
+ <body>
6
+ <p>This is not a valid Google Patents page (interstitial, error page, or markup change) - it has no meta[name=DC.title].</p>
7
+ </body>
8
+ </html>
tests/fixtures/patents/search_results.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <search-result-item>
5
+ <span>US11930446B2</span>
6
+ <h3>Widget apparatus</h3>
7
+ <div class="abstract">An apparatus comprising a widget.</div>
8
+ </search-result-item>
9
+ <search-result-item>
10
+ <span>EP4760514A1</span>
11
+ <h4>Gadget system</h4>
12
+ <div class="result-snippet">A system comprising a gadget.</div>
13
+ </search-result-item>
14
+ </body>
15
+ </html>
tests/fixtures/scholar_results.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <div class="gs_ri">
5
+ <h3><a href="https://example.org/scholar-a">Scholar Paper A</a></h3>
6
+ <div class="gs_rs">Scholar snippet A</div>
7
+ </div>
8
+ <div class="gs_ri">
9
+ <h3><a href="https://example.org/scholar-b">Scholar Paper B</a></h3>
10
+ <div class="gs_rs">Scholar snippet B</div>
11
+ </div>
12
+ </body>
13
+ </html>
tests/helpers.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
8
+ FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
9
+
10
+
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)
tests/test_ops_client.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import httpx
5
+ import pytest
6
+ import respx
7
+
8
+ import ops as ops_module
9
+ from ops import OPSNotConfigured, _OPSTokenManager, _ops_get, ops_scrap_patent, ops_search
10
+
11
+ FIXTURES = Path(__file__).parent / "fixtures" / "ops"
12
+ TOKEN_URL = "https://ops.epo.org/3.2/auth/accesstoken"
13
+ OPS_BASE = "https://ops.epo.org/3.2/rest-services"
14
+
15
+
16
+ def load_json(name: str):
17
+ return json.loads((FIXTURES / name).read_text())
18
+
19
+
20
+ def mock_token_endpoint():
21
+ return respx.post(TOKEN_URL).mock(
22
+ return_value=httpx.Response(200, json=load_json("token_response.json"))
23
+ )
24
+
25
+
26
+ @pytest.fixture
27
+ def fresh_token_manager(monkeypatch):
28
+ """A token manager with test credentials, swapped in for the module-level
29
+ singleton so `_ops_get`/`ops_search`/etc. (which reach for the module
30
+ global `token_manager` directly) pick it up.
31
+ """
32
+ tm = _OPSTokenManager()
33
+ tm._key = "test-key"
34
+ tm._secret = "test-secret"
35
+ monkeypatch.setattr(ops_module, "token_manager", tm)
36
+ return tm
37
+
38
+
39
+ class TestTokenManager:
40
+ async def test_not_configured_raises(self):
41
+ tm = _OPSTokenManager()
42
+ tm._key = None
43
+ tm._secret = None
44
+ async with httpx.AsyncClient() as client:
45
+ with pytest.raises(OPSNotConfigured):
46
+ await tm.get_token(client)
47
+
48
+ async def test_fetches_and_caches_the_token(self):
49
+ tm = _OPSTokenManager()
50
+ tm._key = "k"
51
+ tm._secret = "s"
52
+ with respx.mock:
53
+ route = mock_token_endpoint()
54
+ async with httpx.AsyncClient() as client:
55
+ token1 = await tm.get_token(client)
56
+ token2 = await tm.get_token(client)
57
+
58
+ assert token1 == "test-access-token-123"
59
+ assert token2 == "test-access-token-123"
60
+ assert route.call_count == 1
61
+
62
+ async def test_force_refresh_refetches_even_if_cached(self):
63
+ tm = _OPSTokenManager()
64
+ tm._key = "k"
65
+ tm._secret = "s"
66
+ with respx.mock:
67
+ route = mock_token_endpoint()
68
+ async with httpx.AsyncClient() as client:
69
+ await tm.get_token(client)
70
+ await tm.get_token(client, force=True)
71
+
72
+ assert route.call_count == 2
73
+
74
+
75
+ class TestOpsGet:
76
+ async def test_retries_once_when_the_token_is_stale(self, fresh_token_manager):
77
+ with respx.mock:
78
+ mock_token_endpoint()
79
+ get_route = respx.get(f"{OPS_BASE}/some/path").mock(
80
+ side_effect=[
81
+ httpx.Response(401, text="Invalid access token."),
82
+ httpx.Response(200, json={"ok": True}),
83
+ ]
84
+ )
85
+ async with httpx.AsyncClient() as client:
86
+ data = await _ops_get(client, "/some/path")
87
+
88
+ assert data == {"ok": True}
89
+ assert get_route.call_count == 2
90
+
91
+ async def test_propagates_non_token_errors(self, fresh_token_manager):
92
+ with respx.mock:
93
+ mock_token_endpoint()
94
+ respx.get(f"{OPS_BASE}/some/path").mock(return_value=httpx.Response(500, text="server error"))
95
+ async with httpx.AsyncClient() as client:
96
+ with pytest.raises(httpx.HTTPStatusError):
97
+ await _ops_get(client, "/some/path")
98
+
99
+
100
+ class TestOpsSearch:
101
+ async def test_maps_ops_search_results_to_serp_dicts(self, fresh_token_manager):
102
+ with respx.mock:
103
+ mock_token_endpoint()
104
+ respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
105
+ return_value=httpx.Response(200, json=load_json("search_response.json"))
106
+ )
107
+ async with httpx.AsyncClient() as client:
108
+ results = await ops_search(client, "widget", 10)
109
+
110
+ assert results == [
111
+ {
112
+ "title": "Widget apparatus",
113
+ "body": "An apparatus comprising a widget.",
114
+ "href": "https://worldwide.espacenet.com/patent/search?q=US11930446",
115
+ "id": "US11930446",
116
+ },
117
+ {
118
+ "title": "Gadget system",
119
+ "body": "A system comprising a gadget.",
120
+ "href": "https://worldwide.espacenet.com/patent/search?q=EP4760514",
121
+ "id": "EP4760514",
122
+ },
123
+ ]
124
+
125
+ async def test_returns_empty_list_on_404_not_found(self, fresh_token_manager):
126
+ with respx.mock:
127
+ mock_token_endpoint()
128
+ respx.get(f"{OPS_BASE}/published-data/search/biblio").mock(
129
+ return_value=httpx.Response(404, json=load_json("search_not_found_response.json"))
130
+ )
131
+ async with httpx.AsyncClient() as client:
132
+ results = await ops_search(client, "nonexistent widget xyz", 10)
133
+
134
+ assert results == []
135
+
136
+
137
+ class TestOpsScrapPatent:
138
+ async def test_assembles_a_full_patent_from_three_endpoints(self, fresh_token_manager):
139
+ number = "US11930446"
140
+ with respx.mock:
141
+ mock_token_endpoint()
142
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
143
+ return_value=httpx.Response(200, json=load_json("biblio_response.json"))
144
+ )
145
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
146
+ return_value=httpx.Response(200, json=load_json("claims_response.json"))
147
+ )
148
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
149
+ return_value=httpx.Response(200, json=load_json("description_response.json"))
150
+ )
151
+ async with httpx.AsyncClient() as client:
152
+ # Kind-code suffix ("B2") should be normalized away before
153
+ # any of the three requests go out.
154
+ result = await ops_scrap_patent(client, "US11930446B2")
155
+
156
+ assert result.title == "Widget apparatus"
157
+ assert result.abstract == "An apparatus comprising a widget."
158
+ assert result.claims == "1. A widget comprising a gadget.\n2. The widget of claim 1, further comprising a mechanism."
159
+ assert result.description == "This invention relates to widgets.\nThe widget comprises a gadget and a mechanism."
160
+ assert {c.code for c in result.classifications} == {"G06F17/30", "H04L9/00"}
161
+
162
+ async def test_missing_fulltext_degrades_to_none_instead_of_raising(self, fresh_token_manager):
163
+ number = "US11930446"
164
+ with respx.mock:
165
+ mock_token_endpoint()
166
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/biblio").mock(
167
+ return_value=httpx.Response(200, json=load_json("biblio_response.json"))
168
+ )
169
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/claims").mock(
170
+ return_value=httpx.Response(404)
171
+ )
172
+ respx.get(f"{OPS_BASE}/published-data/publication/epodoc/{number}/description").mock(
173
+ return_value=httpx.Response(404)
174
+ )
175
+ async with httpx.AsyncClient() as client:
176
+ result = await ops_scrap_patent(client, number)
177
+
178
+ assert result.title == "Widget apparatus"
179
+ assert result.claims is None
180
+ assert result.description is None
tests/test_ops_parsing.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for ops.py's pure JSON-walking helpers.
2
+
3
+ OPS's XML-to-JSON conversion is the trickiest part of this module: text
4
+ nodes become {"$": "..."}, attributes become "@name" keys, and a repeated
5
+ XML element becomes a list *unless there's only one*, in which case it stays
6
+ a bare dict. Every helper here has to handle both shapes; these tests pin
7
+ both for each one.
8
+ """
9
+
10
+ import pytest
11
+
12
+ from ops import (
13
+ _as_list,
14
+ _text,
15
+ _pick_lang,
16
+ _epodoc_number,
17
+ _extract_title,
18
+ _extract_abstract,
19
+ _extract_classifications,
20
+ _fulltext_paragraphs,
21
+ _to_cql,
22
+ _normalize_epodoc,
23
+ )
24
+
25
+
26
+ class TestAsList:
27
+ def test_none_becomes_empty_list(self):
28
+ assert _as_list(None) == []
29
+
30
+ def test_single_dict_is_wrapped(self):
31
+ assert _as_list({"a": 1}) == [{"a": 1}]
32
+
33
+ def test_list_passes_through_unchanged(self):
34
+ assert _as_list([{"a": 1}, {"a": 2}]) == [{"a": 1}, {"a": 2}]
35
+
36
+
37
+ class TestText:
38
+ def test_extracts_dollar_key(self):
39
+ assert _text({"$": "hello"}) == "hello"
40
+
41
+ def test_plain_string_passes_through(self):
42
+ assert _text("hello") == "hello"
43
+
44
+ def test_none_for_anything_else(self):
45
+ assert _text(None) is None
46
+ assert _text({"@lang": "en"}) is None
47
+
48
+
49
+ class TestPickLang:
50
+ def test_picks_requested_language_from_list(self):
51
+ nodes = [{"@lang": "de", "$": "Hallo"}, {"@lang": "en", "$": "Hello"}]
52
+ assert _pick_lang(nodes) == {"@lang": "en", "$": "Hello"}
53
+
54
+ def test_falls_back_to_first_when_language_missing(self):
55
+ nodes = [{"@lang": "de", "$": "Hallo"}, {"@lang": "fr", "$": "Bonjour"}]
56
+ assert _pick_lang(nodes) == {"@lang": "de", "$": "Hallo"}
57
+
58
+ def test_single_dict_not_wrapped_in_a_list_still_works(self):
59
+ node = {"@lang": "en", "$": "Hello"}
60
+ assert _pick_lang(node) == node
61
+
62
+ def test_empty_input_returns_none(self):
63
+ assert _pick_lang(None) is None
64
+ assert _pick_lang([]) is None
65
+
66
+
67
+ class TestEpodocNumber:
68
+ def test_prefers_the_epodoc_document_id(self):
69
+ exchange_doc = {
70
+ "@country": "US",
71
+ "@doc-number": "11930446",
72
+ "bibliographic-data": {
73
+ "publication-reference": {
74
+ "document-id": [
75
+ {"@document-id-type": "docdb", "doc-number": {"$": "11930446"}},
76
+ {"@document-id-type": "epodoc", "doc-number": {"$": "US11930446"}},
77
+ ]
78
+ }
79
+ },
80
+ }
81
+ assert _epodoc_number(exchange_doc) == "US11930446"
82
+
83
+ def test_falls_back_to_country_plus_doc_number(self):
84
+ exchange_doc = {"@country": "US", "@doc-number": "11930446", "bibliographic-data": {}}
85
+ assert _epodoc_number(exchange_doc) == "US11930446"
86
+
87
+ def test_none_when_nothing_available(self):
88
+ assert _epodoc_number({"bibliographic-data": {}}) is None
89
+
90
+
91
+ class TestExtractTitle:
92
+ def test_from_a_list_of_languages(self):
93
+ bd = {"invention-title": [{"@lang": "en", "$": "Widget"}, {"@lang": "de", "$": "Widget-DE"}]}
94
+ assert _extract_title(bd) == "Widget"
95
+
96
+ def test_from_a_single_bare_dict(self):
97
+ bd = {"invention-title": {"@lang": "en", "$": "Widget"}}
98
+ assert _extract_title(bd) == "Widget"
99
+
100
+ def test_none_when_missing(self):
101
+ assert _extract_title({}) is None
102
+
103
+
104
+ class TestExtractAbstract:
105
+ def test_joins_paragraphs_from_a_list(self):
106
+ doc = {"abstract": [{"@lang": "en", "p": [{"$": "Part one."}, {"$": "Part two."}]}]}
107
+ assert _extract_abstract(doc) == "Part one. Part two."
108
+
109
+ def test_single_paragraph_not_wrapped_in_a_list(self):
110
+ doc = {"abstract": {"@lang": "en", "p": {"$": "Only paragraph."}}}
111
+ assert _extract_abstract(doc) == "Only paragraph."
112
+
113
+ def test_none_when_missing(self):
114
+ assert _extract_abstract({}) is None
115
+
116
+
117
+ class TestExtractClassifications:
118
+ def test_builds_codes_from_the_five_part_symbol(self):
119
+ bd = {
120
+ "patent-classifications": {
121
+ "patent-classification": [
122
+ {
123
+ "section": {"$": "G"},
124
+ "class": {"$": "06"},
125
+ "subclass": {"$": "F"},
126
+ "main-group": {"$": "17"},
127
+ "subgroup": {"$": "30"},
128
+ }
129
+ ]
130
+ }
131
+ }
132
+ codes = _extract_classifications(bd)
133
+ assert len(codes) == 1
134
+ assert codes[0].code == "G06F17/30"
135
+ assert codes[0].description == ""
136
+
137
+ def test_dedupes_and_skips_incomplete_entries(self):
138
+ bd = {
139
+ "patent-classifications": {
140
+ "patent-classification": [
141
+ {"section": {"$": "G"}, "class": {"$": "06"}, "subclass": {"$": "F"}, "main-group": {"$": "17"}, "subgroup": {"$": "30"}},
142
+ {"section": {"$": "G"}, "class": {"$": "06"}, "subclass": {"$": "F"}, "main-group": {"$": "17"}, "subgroup": {"$": "30"}},
143
+ {"section": {"$": "H"}}, # incomplete - no class/subclass/main-group
144
+ ]
145
+ }
146
+ }
147
+ codes = _extract_classifications(bd)
148
+ assert [c.code for c in codes] == ["G06F17/30"]
149
+
150
+ def test_none_when_no_classifications(self):
151
+ assert _extract_classifications({}) is None
152
+
153
+
154
+ class TestFulltextParagraphs:
155
+ def test_claims_join_claim_text_lines(self):
156
+ data = {
157
+ "ops:world-patent-data": {
158
+ "ftxt:fulltext-documents": {
159
+ "ftxt:fulltext-document": {
160
+ "claims": {
161
+ "@lang": "en",
162
+ "claim": [
163
+ {"claim-text": {"$": "1. A widget."}},
164
+ {"claim-text": [{"$": "2. A gadget,"}, {"$": "further comprising a mechanism."}]},
165
+ ],
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+ assert _fulltext_paragraphs(data, "claims") == (
172
+ "1. A widget.\n2. A gadget,\nfurther comprising a mechanism."
173
+ )
174
+
175
+ def test_description_joins_paragraphs(self):
176
+ data = {
177
+ "ops:world-patent-data": {
178
+ "ftxt:fulltext-documents": {
179
+ "ftxt:fulltext-document": {
180
+ "description": {"@lang": "en", "p": [{"$": "Para one."}, {"$": "Para two."}]}
181
+ }
182
+ }
183
+ }
184
+ }
185
+ assert _fulltext_paragraphs(data, "description") == "Para one.\nPara two."
186
+
187
+ def test_none_when_document_missing(self):
188
+ assert _fulltext_paragraphs({"ops:world-patent-data": {}}, "claims") is None
189
+
190
+
191
+ @pytest.mark.parametrize(
192
+ "raw, expected",
193
+ [
194
+ ("widget", 'txt all "widget"'),
195
+ ("agentic ai", 'txt all "agentic ai"'),
196
+ ('pn = "US1234"', 'pn = "US1234"'),
197
+ ("", ""),
198
+ ('has "quotes"', 'txt all "has quotes"'),
199
+ ],
200
+ )
201
+ def test_to_cql(raw, expected):
202
+ assert _to_cql(raw) == expected
203
+
204
+
205
+ @pytest.mark.parametrize(
206
+ "raw, expected",
207
+ [
208
+ ("US11930446B2", "US11930446"),
209
+ ("EP4760514A1", "EP4760514"),
210
+ ("US 11930446 B2", "US11930446"),
211
+ ("US11930446", "US11930446"),
212
+ ],
213
+ )
214
+ def test_normalize_epodoc(raw, expected):
215
+ assert _normalize_epodoc(raw) == expected
tests/test_scrap.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import pytest
3
+ import respx
4
+
5
+ from helpers import load_fixture
6
+ from scrap import scrap_patent_async, scrap_patent_bulk_async
7
+
8
+ FULL_PATENT_HTML = load_fixture("patents", "full_patent.html")
9
+ MISSING_TITLE_HTML = load_fixture("patents", "missing_title.html")
10
+
11
+
12
+ async def test_scrap_patent_async_extracts_all_fields():
13
+ url = "https://patents.google.com/patent/US11930446B2/en"
14
+ with respx.mock:
15
+ respx.get(url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
16
+ async with httpx.AsyncClient() as client:
17
+ result = await scrap_patent_async(client, url)
18
+
19
+ assert result.title == "Widget with improved gadget mechanism"
20
+ assert result.abstract == "A widget comprising a gadget and a mechanism for improving widget performance."
21
+ assert result.field_of_invention == "This invention relates to widgets and gadgets for testing purposes."
22
+ assert result.background == "Prior art widgets suffered from several problems described herein."
23
+ assert "1. A widget comprising a gadget." in result.claims
24
+ assert "2. The widget of claim 1, further comprising a mechanism." in result.claims
25
+
26
+ codes = {c.code: c.description for c in result.classifications}
27
+ assert codes == {
28
+ "G06F17/30": "Database structures therefor",
29
+ "G06F17/50": "Other database related",
30
+ "H04L9/00": "Cryptographic mechanisms",
31
+ }
32
+
33
+
34
+ async def test_scrap_patent_async_raises_when_page_has_no_title():
35
+ """Pins the documented behavior at scrap.py: an unexpected page layout
36
+ (interstitial, non-patent page, markup change) raises ValueError rather
37
+ than crashing on `None.get(...)`, so callers can treat it as a failed
38
+ scrape.
39
+ """
40
+ url = "https://patents.google.com/patent/BOGUS/en"
41
+ with respx.mock:
42
+ respx.get(url).mock(return_value=httpx.Response(200, text=MISSING_TITLE_HTML))
43
+ async with httpx.AsyncClient() as client:
44
+ with pytest.raises(ValueError):
45
+ await scrap_patent_async(client, url)
46
+
47
+
48
+ async def test_scrap_patent_async_raises_on_http_error():
49
+ url = "https://patents.google.com/patent/US00000000/en"
50
+ with respx.mock:
51
+ respx.get(url).mock(return_value=httpx.Response(404))
52
+ async with httpx.AsyncClient() as client:
53
+ with pytest.raises(httpx.HTTPStatusError):
54
+ await scrap_patent_async(client, url)
55
+
56
+
57
+ async def test_scrap_patent_bulk_async_separates_successes_from_failures():
58
+ ok_url = "https://patents.google.com/patent/US_OK/en"
59
+ fail_url = "https://patents.google.com/patent/US_FAIL/en"
60
+ with respx.mock:
61
+ respx.get(ok_url).mock(return_value=httpx.Response(200, text=FULL_PATENT_HTML))
62
+ respx.get(fail_url).mock(return_value=httpx.Response(404))
63
+ async with httpx.AsyncClient() as client:
64
+ result = await scrap_patent_bulk_async(client, ["US_OK", "US_FAIL"])
65
+
66
+ assert len(result.patents) == 1
67
+ assert result.patents[0].title == "Widget with improved gadget mechanism"
68
+ assert result.failed_ids == ["US_FAIL"]
tests/test_serp_arxiv.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import respx
3
+
4
+ from helpers import load_fixture
5
+ from serp import query_arxiv
6
+
7
+ ARXIV_XML = load_fixture("arxiv_response.xml")
8
+
9
+
10
+ async def test_query_arxiv_parses_the_atom_feed():
11
+ with respx.mock:
12
+ respx.get("https://export.arxiv.org/api/query").mock(
13
+ return_value=httpx.Response(200, text=ARXIV_XML, headers={"content-type": "application/atom+xml"})
14
+ )
15
+ async with httpx.AsyncClient() as client:
16
+ results = await query_arxiv(client, "widgets", 2)
17
+
18
+ assert results == [
19
+ {
20
+ "title": "A Study of Widgets",
21
+ "href": "http://arxiv.org/pdf/2401.00001v1",
22
+ "body": "This paper studies widgets in depth.",
23
+ "id": "http://arxiv.org/abs/2401.00001v1",
24
+ },
25
+ {
26
+ "title": "Gadgets and Their Applications",
27
+ "href": "http://arxiv.org/pdf/2401.00002v1",
28
+ "body": "An overview of gadgets.",
29
+ "id": "http://arxiv.org/abs/2401.00002v1",
30
+ },
31
+ ]
tests/test_serp_ddg.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ import serp
4
+ from serp import DuckDuckGoBlockedException, query_ddg_search
5
+
6
+
7
+ class _FakeDDGS:
8
+ def __init__(self, results):
9
+ self._results = results
10
+
11
+ def text(self, q, max_results=10):
12
+ return iter(self._results)
13
+
14
+
15
+ async def test_maps_duckduckgo_results_to_serp_dicts(monkeypatch):
16
+ monkeypatch.setattr(
17
+ serp,
18
+ "DDGS",
19
+ lambda: _FakeDDGS(
20
+ [
21
+ {"title": "A", "body": "Body A", "href": "https://a.example"},
22
+ {"title": "B", "body": "Body B", "href": "https://b.example"},
23
+ ]
24
+ ),
25
+ )
26
+
27
+ results = await query_ddg_search("test query", 10)
28
+
29
+ assert results == [
30
+ {"title": "A", "body": "Body A", "href": "https://a.example"},
31
+ {"title": "B", "body": "Body B", "href": "https://b.example"},
32
+ ]
33
+
34
+
35
+ async def test_raises_blocked_exception_on_empty_results(monkeypatch):
36
+ """Pins the fix from commit d341a7a: duckduckgo_search doesn't always
37
+ raise when it's rate-limited/blocked - it can just parse zero results
38
+ from the anti-bot page. An empty list must be treated as a failure so
39
+ the `/serp/search` fallback chain moves on to the next backend.
40
+ """
41
+ monkeypatch.setattr(serp, "DDGS", lambda: _FakeDDGS([]))
42
+
43
+ with pytest.raises(DuckDuckGoBlockedException):
44
+ await query_ddg_search("test query", 10)
tests/test_serp_models.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from serp import SerpQuery
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ "raw, expected",
8
+ [
9
+ (10, 10),
10
+ (1, 1),
11
+ (100, 100),
12
+ (0, 1),
13
+ (-5, 1),
14
+ (500, 100),
15
+ (101, 100),
16
+ ],
17
+ )
18
+ def test_n_results_is_clamped_to_1_100(raw, expected):
19
+ q = SerpQuery(queries=["test"], n_results=raw)
20
+ assert q.n_results == expected
21
+
22
+
23
+ def test_n_results_defaults_to_10():
24
+ q = SerpQuery(queries=["test"])
25
+ assert q.n_results == 10
tests/test_serp_playwright.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ playwright_open_page,
17
+ query_bing_search,
18
+ query_brave_search,
19
+ query_google_patents,
20
+ query_google_scholar,
21
+ )
22
+
23
+
24
+ async def test_bing_search_parses_results(browser):
25
+ fixture_browser = make_fixture_browser(browser, load_fixture("bing_results.html"))
26
+
27
+ results = await query_bing_search(fixture_browser, "test query", 10)
28
+
29
+ assert results == [
30
+ {"title": "Result A", "href": "https://example.org/a", "body": "Snippet A"},
31
+ {"title": "Result B", "href": "https://example.org/b", "body": "Snippet B"},
32
+ ]
33
+
34
+
35
+ async def test_brave_search_parses_results(browser):
36
+ fixture_browser = make_fixture_browser(browser, load_fixture("brave_results.html"))
37
+
38
+ results = await query_brave_search(fixture_browser, "test query", 10)
39
+
40
+ assert results == [
41
+ {"title": "Brave Result A", "body": "Brave snippet A", "href": "https://example.org/brave-a"},
42
+ {"title": "Brave Result B", "body": "Brave snippet B", "href": "https://example.org/brave-b"},
43
+ ]
44
+
45
+
46
+ async def test_brave_search_detects_the_suspicious_block_page(browser):
47
+ fixture_browser = make_fixture_browser(browser, load_fixture("brave_blocked.html"))
48
+
49
+ with pytest.raises(BraveSearchBlockedException):
50
+ await query_brave_search(fixture_browser, "test query", 10)
51
+
52
+
53
+ async def test_google_scholar_parses_results(browser):
54
+ fixture_browser = make_fixture_browser(browser, load_fixture("scholar_results.html"))
55
+
56
+ results = await query_google_scholar(fixture_browser, "test query", 10)
57
+
58
+ assert results == [
59
+ {"title": "Scholar Paper A", "body": "Scholar snippet A", "href": "https://example.org/scholar-a"},
60
+ {"title": "Scholar Paper B", "body": "Scholar snippet B", "href": "https://example.org/scholar-b"},
61
+ ]
62
+
63
+
64
+ async def test_google_patents_parses_results(browser):
65
+ fixture_browser = make_fixture_browser(browser, load_fixture("patents", "search_results.html"))
66
+
67
+ results = await query_google_patents(fixture_browser, "test query", 10)
68
+
69
+ assert results == [
70
+ {
71
+ "id": "US11930446B2",
72
+ "href": "https://patents.google.com/patent/US11930446B2/en",
73
+ "title": "Widget apparatus",
74
+ "body": "An apparatus comprising a widget.",
75
+ },
76
+ {
77
+ "id": "EP4760514A1",
78
+ "href": "https://patents.google.com/patent/EP4760514A1/en",
79
+ "title": "Gadget system",
80
+ "body": "A system comprising a gadget.",
81
+ },
82
+ ]
83
+
84
+
85
+ async def test_playwright_open_page_raises_when_browser_unavailable():
86
+ with pytest.raises(BrowserUnavailableError):
87
+ async with playwright_open_page(None):
88
+ pass
tests/test_utils.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
16
+ assert "q2" in warnings[0].message
17
+ assert "boom" in warnings[0].message
18
+ assert "test context" in warnings[0].message
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
+
30
+
31
+ def test_uses_the_asyncio_logger_not_an_app_logger(caplog):
32
+ """Characterizes a real bug (flagged in review): `utils.py` imports
33
+ `logger` from `asyncio.log` instead of using `logging.getLogger(__name__)`,
34
+ so these warnings are misattributed to the 'asyncio' logging namespace
35
+ instead of the app's own. Pinned here so the fix (giving `utils.py` its
36
+ own logger) shows up as an intentional assertion change, not a silent
37
+ behavior drift.
38
+ """
39
+ params = SerpQuery(queries=["q1"])
40
+ with caplog.at_level(logging.WARNING):
41
+ log_gathered_exceptions([ValueError("boom")], "ctx", params)
42
+
43
+ assert caplog.records[0].name == "asyncio"