File size: 10,010 Bytes
e44fdef
 
 
 
 
 
 
3352a6e
 
 
 
 
 
 
e44fdef
 
3352a6e
 
 
 
 
 
 
 
 
e44fdef
 
 
 
 
 
 
 
3352a6e
e44fdef
 
3352a6e
 
e44fdef
 
3352a6e
e44fdef
 
3352a6e
e44fdef
 
 
 
 
3352a6e
e44fdef
 
3352a6e
 
e44fdef
 
 
3352a6e
e44fdef
 
 
3352a6e
e44fdef
 
 
3352a6e
e44fdef
 
 
3352a6e
e44fdef
 
 
3352a6e
 
e44fdef
3352a6e
 
e44fdef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3352a6e
e44fdef
3352a6e
 
e44fdef
 
3352a6e
 
e44fdef
 
 
 
 
 
 
 
3352a6e
e44fdef
3352a6e
 
e44fdef
3352a6e
 
e44fdef
 
 
 
 
 
 
 
3352a6e
e44fdef
3352a6e
e44fdef
 
3352a6e
 
55c0bf1
 
 
e44fdef
 
 
 
 
 
55c0bf1
 
 
e44fdef
 
 
 
 
55c0bf1
e44fdef
 
 
55c0bf1
 
 
 
 
 
 
e44fdef
55c0bf1
 
e44fdef
 
 
 
55c0bf1
e44fdef
 
 
55c0bf1
 
e44fdef
 
 
 
 
 
55c0bf1
e44fdef
3352a6e
 
e44fdef
 
3352a6e
e44fdef
 
285bc99
 
 
 
 
e44fdef
 
 
 
 
 
 
 
285bc99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9841346
 
 
 
285bc99
 
 
 
 
 
ba2278a
 
9841346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
"""app.py: HTTP wiring only.

The orchestration these endpoints used to contain now lives in
services.py and is tested in test_search_service.py and
test_patent_fallback.py without an HTTP layer. What's left here is what
app.py is actually responsible for: request validation, dependency wiring,
the lifespan, and turning domain results into responses.
"""

import httpx
import pytest
from httpx import ASGITransport

import app as app_module
from scrap import PatentScrapBulkResponse, PatentScrapResult
from serp import SerpResults


@pytest.fixture
async def client():
    transport = ASGITransport(app=app_module.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
        yield c


@pytest.fixture
def override_services():
    """Install stand-in services for the duration of one test."""
    def _install(*, search=None, patent=None):
        if search is not None:
            app_module.app.dependency_overrides[app_module.get_search_service] = lambda: search
        if patent is not None:
            app_module.app.dependency_overrides[app_module.get_patent_service] = lambda: patent

    yield _install
    app_module.app.dependency_overrides.clear()


class _RecordingSearchService:
    """Records which service method each endpoint dispatched to."""

    def __init__(self):
        self.calls = []

    def _record(self, name):
        async def _fn(params):
            self.calls.append((name, list(params.queries), params.n_results))
            return SerpResults(results=[{"title": name}], error=None)
        return _fn

    def __getattr__(self, name):
        return self._record(name)


class _StubPatentService:
    def __init__(self):
        self.calls = []

    async def scrap(self, patent_id):
        self.calls.append(("scrap", patent_id))
        return PatentScrapResult(title="Widget apparatus")

    async def scrap_bulk(self, patent_ids):
        self.calls.append(("scrap_bulk", patent_ids))
        return PatentScrapBulkResponse(patents=[], failed_ids=list(patent_ids))

    async def ops_scrap(self, patent_id):
        self.calls.append(("ops_scrap", patent_id))
        return PatentScrapResult(title="From OPS")

    async def ops_scrap_bulk(self, patent_ids):
        self.calls.append(("ops_scrap_bulk", patent_ids))
        return PatentScrapBulkResponse(patents=[], failed_ids=list(patent_ids))


# ------------------------------- endpoint dispatch -------------------------------


@pytest.mark.parametrize("path, expected_method", [
    ("/serp/search_scholar", "google_scholar"),
    ("/serp/search_arxiv", "arxiv"),
    ("/serp/search_patents", "patents"),
    ("/serp/search_brave", "brave"),
    ("/serp/search_bing", "bing"),
    ("/serp/search_duck", "duckduckgo"),
    ("/serp/search", "search"),
    ("/ops/search", "ops_keyword_search"),
])
async def test_each_search_endpoint_dispatches_to_its_service_method(
        client, override_services, path, expected_method):
    """Guards the wiring itself: every endpoint reaching the right method
    with the request's queries and result count intact."""
    service = _RecordingSearchService()
    override_services(search=service)

    resp = await client.post(path, json={"queries": ["a", "b"], "n_results": 25})

    assert resp.status_code == 200
    assert service.calls == [(expected_method, ["a", "b"], 25)]
    assert resp.json()["results"] == [{"title": expected_method}]


@pytest.mark.parametrize("method, path, expected_call", [
    ("get", "/scrap/scrap_patent/US11930446B2", ("scrap", "US11930446B2")),
    ("get", "/ops/scrap_patent/US11930446B2", ("ops_scrap", "US11930446B2")),
])
async def test_patent_endpoints_dispatch_to_their_service_method(
        client, override_services, method, path, expected_call):
    service = _StubPatentService()
    override_services(patent=service)

    resp = await getattr(client, method)(path)

    assert resp.status_code == 200
    assert service.calls == [expected_call]


@pytest.mark.parametrize("path, expected_method", [
    ("/scrap/scrap_patents_bulk", "scrap_bulk"),
    ("/ops/scrap_patents_bulk", "ops_scrap_bulk"),
])
async def test_bulk_endpoints_dispatch_to_their_service_method(
        client, override_services, path, expected_method):
    service = _StubPatentService()
    override_services(patent=service)

    resp = await client.post(path, json={"patent_ids": ["US11930446B2"]})

    assert resp.status_code == 200
    assert service.calls == [(expected_method, ["US11930446B2"])]


# ------------------------------- patent_id validation -------------------------------


@pytest.mark.parametrize("path", [
    "/scrap/scrap_patent/not-a-patent-id",
    "/ops/scrap_patent/not-a-patent-id",
])
async def test_malformed_patent_id_is_rejected(client, path):
    resp = await client.get(path)
    assert resp.status_code == 422


@pytest.mark.parametrize("path", [
    "/scrap/scrap_patents_bulk", "/ops/scrap_patents_bulk"])
async def test_malformed_patent_id_in_a_bulk_list_is_rejected(client, path):
    resp = await client.post(path, json={"patent_ids": ["US11930446B2", "garbage!!"]})
    assert resp.status_code == 422


async def test_well_formed_patent_id_is_accepted(client, override_services):
    override_services(patent=_StubPatentService())

    resp = await client.get("/scrap/scrap_patent/US11930446B2")

    assert resp.status_code == 200
    assert resp.json()["title"] == "Widget apparatus"


# ---------------------------- default dependency wiring ----------------------------


def test_the_default_search_service_gets_the_apps_collaborators():
    """The providers exist so tests can override them; this pins that the
    un-overridden ones still hand the service the real collaborators."""
    service = app_module.get_search_service()

    assert service._http is app_module.httpx_client
    assert service._breaker is app_module._backend_circuit_breaker
    assert service._ops_tokens is app_module.ops_token_manager


def test_the_search_service_reads_the_browser_lazily(monkeypatch):
    """The browser is started by the lifespan, after the service may already
    exist, and stays None when Playwright fails to start - so the service
    has to read it per call rather than capture it at construction."""
    service = app_module.get_search_service()
    monkeypatch.setattr(app_module, "pw_browser", "a-browser")

    assert service.browser == "a-browser"


def test_the_default_patent_service_gets_the_apps_collaborators():
    service = app_module.get_patent_service()

    assert service._http is app_module.httpx_client
    assert service._ops_tokens is app_module.ops_token_manager


# ------------------------------------- api_lifespan -------------------------------------


class _RecordingClient:
    def __init__(self):
        self.closed = False

    async def aclose(self):
        self.closed = True


async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
    """The container runs as a non-root user (see the Dockerfile), and
    Chromium's own internal sandbox needs privileges a non-root container
    user doesn't have - so it must be launched with --no-sandbox, or it
    fails to start at all in that environment.
    """
    launch_calls = []

    class FakeBrowser:
        async def close(self):
            pass

    class FakeChromium:
        async def launch(self, **kwargs):
            launch_calls.append(kwargs)
            return FakeBrowser()

    class FakePlaywright:
        chromium = FakeChromium()

        async def stop(self):
            pass

    class FakePlaywrightContextManager:
        async def start(self):
            return FakePlaywright()

    # Reset via monkeypatch (not a plain assignment) so its automatic
    # teardown restores whatever these module globals held before this test,
    # regardless of what api_lifespan reassigns them to while it runs.
    monkeypatch.setattr(app_module, "pw_browser", None)
    monkeypatch.setattr(app_module, "playwright", None)
    monkeypatch.setattr(app_module, "async_playwright", lambda: FakePlaywrightContextManager())
    # The lifespan also closes the shared httpx client on shutdown; swap in
    # a stand-in so this test doesn't close the real one for the rest of
    # the session.
    monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())

    async with app_module.api_lifespan(app_module.app):
        pass

    assert launch_calls[0]["headless"] is True
    assert "--no-sandbox" in launch_calls[0].get("args", [])


async def test_api_lifespan_closes_the_http_client_on_shutdown(monkeypatch):
    """The client was created at import and never closed - the browser was
    torn down on shutdown but its connection pool was not.
    """
    recording_client = _RecordingClient()
    monkeypatch.setattr(app_module, "pw_browser", None)
    monkeypatch.setattr(app_module, "playwright", None)
    monkeypatch.setattr(app_module, "httpx_client", recording_client)
    # Playwright failing to start is the documented degraded mode; the
    # client must still be closed cleanly on the way out.
    monkeypatch.setattr(
        app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))

    async with app_module.api_lifespan(app_module.app):
        pass

    assert recording_client.closed is True


async def test_api_lifespan_survives_playwright_failing_to_start(monkeypatch):
    """Documented behaviour: the app still comes up, pw_browser stays None,
    and the browser-backed endpoints fail individually rather than the whole
    deployment refusing to boot.
    """
    monkeypatch.setattr(app_module, "pw_browser", None)
    monkeypatch.setattr(app_module, "playwright", None)
    monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())
    monkeypatch.setattr(
        app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))

    async with app_module.api_lifespan(app_module.app):
        assert app_module.pw_browser is None