From d479edeb3f69383cfc1f71198a8da35a8564ba58 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 26 Jun 2026 09:30:35 +0000 Subject: [PATCH] feat(tradein-browser): additive POST /fetch-json + BrowserFetcher.fetch_json (#915 Stage 2) (#1915) --- .../app/services/scrapers/browser_fetcher.py | 65 +++++ .../tests/scrapers/test_browser_fetcher.py | 142 ++++++++++ tradein-mvp/browser/server.py | 173 ++++++++++- tradein-mvp/browser/test_server_fetch_json.py | 268 ++++++++++++++++++ 4 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 tradein-mvp/browser/test_server_fetch_json.py diff --git a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py index 730415e2..28c1a81a 100644 --- a/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py +++ b/tradein-mvp/backend/app/services/scrapers/browser_fetcher.py @@ -94,6 +94,38 @@ class BrowserFetcher: await asyncio.sleep(_RETRY_SLEEP_S) return await self._post_fetch(url) + async def fetch_json( + self, + url: str, + *, + method: str = "GET", + headers: dict[str, str] | None = None, + body: str | None = None, + origin: str | None = None, + ) -> dict: + """In-page fetch() через sidecar /fetch-json. Возвращает {"status": int, "body": str}. + + body — уже сериализованная строка (caller делает json.dumps для POST). + origin — same-origin страница, на которую перейдёт камуфокс перед fetch. + + При HTTPError / TransportError делает одну повторную попытку после короткой + паузы. Остальные исключения всплывают к вызывающему коду. + """ + assert self._client is not None, "BrowserFetcher: используй как async context manager" + assert self._endpoint is not None, "BrowserFetcher: endpoint не задан" + + try: + return await self._post_fetch_json(url, method, headers, body, origin) + except (httpx.HTTPError, httpx.TransportError) as exc: + logger.warning( + "BrowserFetcher: ошибка fetch-json запроса (%s), retry через %.1fs: %s", + type(exc).__name__, + _RETRY_SLEEP_S, + url, + ) + await asyncio.sleep(_RETRY_SLEEP_S) + return await self._post_fetch_json(url, method, headers, body, origin) + async def login( self, *, @@ -159,6 +191,39 @@ class BrowserFetcher: logger.debug("BrowserFetcher: fetch OK url=%r html_len=%d", url, len(html)) return html + async def _post_fetch_json( + self, + url: str, + method: str, + headers: dict[str, str] | None, + body: str | None, + origin: str | None, + ) -> dict: + """Один HTTP POST к /fetch-json эндпоинту сервиса.""" + assert self._client is not None + assert self._endpoint is not None + + resp = await self._client.post( + f"{self._endpoint}/fetch-json", + json={ + "url": url, + "source": self._source, + "method": method, + "headers": headers or {}, + "body": body, + "origin": origin, + }, + ) + resp.raise_for_status() + data: dict = resp.json() + logger.debug( + "BrowserFetcher: fetch-json OK url=%r status=%s body_len=%d", + url, + data.get("status"), + len(data.get("body") or ""), + ) + return data + async def _post_login(self, body: dict[str, object]) -> dict[str, str]: """Один HTTP POST к /login эндпоинту сервиса.""" assert self._client is not None diff --git a/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py index 2eff8d63..73c10a90 100644 --- a/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py +++ b/tradein-mvp/backend/tests/scrapers/test_browser_fetcher.py @@ -40,6 +40,15 @@ def _make_ok_response(html: str = "ok") -> MagicMock: return resp +def _make_ok_json_response(status: int = 200, body: str = "{}") -> MagicMock: + """Создаёт мок httpx.Response с JSON {"status": ..., "body": ...} (для /fetch-json).""" + resp = MagicMock(spec=httpx.Response) + resp.status_code = 200 + resp.json = MagicMock(return_value={"status": status, "body": body}) + resp.raise_for_status = MagicMock() # нет исключения + return resp + + def _make_error_response(status: int = 500) -> MagicMock: """Создаёт мок httpx.Response который кидает HTTPStatusError при raise_for_status.""" resp = MagicMock(spec=httpx.Response) @@ -135,6 +144,139 @@ async def test_fetch_posts_source_field_when_set() -> None: ) +# ── fetch_json(): in-page fetch через /fetch-json (#915 Stage 2) ─────────────── + + +@pytest.mark.asyncio +async def test_fetch_json_returns_dict_from_json_response() -> None: + """fetch_json() возвращает {"status","body"} dict из 200-ответа /fetch-json.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + ok_resp = _make_ok_json_response(status=200, body='{"items": []}') + ms = _mock_settings() + + with patch("app.core.config.settings", ms): + fetcher = BrowserFetcher() + async with fetcher: + assert fetcher._client is not None + fetcher._client.post = AsyncMock(return_value=ok_resp) # type: ignore[method-assign] + result = await fetcher.fetch_json("https://avito.ru/api/x") + + assert result == {"status": 200, "body": '{"items": []}'} + + +@pytest.mark.asyncio +async def test_fetch_json_posts_full_payload() -> None: + """fetch_json() POST'ит url/source/method/headers/body/origin к /fetch-json.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + endpoint = "http://fake-browser:3000" + ok_resp = _make_ok_json_response() + ms = _mock_settings(browser_http_endpoint=endpoint) + + with patch("app.core.config.settings", ms): + fetcher = BrowserFetcher(source="avito") + async with fetcher: + assert fetcher._client is not None + post_mock = AsyncMock(return_value=ok_resp) + fetcher._client.post = post_mock # type: ignore[method-assign] + await fetcher.fetch_json( + "https://avito.ru/api/listings", + method="POST", + headers={"content-type": "application/json"}, + body='{"q": 1}', + origin="https://avito.ru/moskva/kvartiry", + ) + + post_mock.assert_called_once_with( + f"{endpoint}/fetch-json", + json={ + "url": "https://avito.ru/api/listings", + "source": "avito", + "method": "POST", + "headers": {"content-type": "application/json"}, + "body": '{"q": 1}', + "origin": "https://avito.ru/moskva/kvartiry", + }, + ) + + +@pytest.mark.asyncio +async def test_fetch_json_defaults_get_empty_headers_none_body() -> None: + """Дефолты: method=GET, headers={}, body=None, origin=None.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + endpoint = "http://fake-browser:3000" + ok_resp = _make_ok_json_response() + ms = _mock_settings(browser_http_endpoint=endpoint) + + with patch("app.core.config.settings", ms): + fetcher = BrowserFetcher(source="cian") + async with fetcher: + assert fetcher._client is not None + post_mock = AsyncMock(return_value=ok_resp) + fetcher._client.post = post_mock # type: ignore[method-assign] + await fetcher.fetch_json("https://cian.ru/api/x") + + post_mock.assert_called_once_with( + f"{endpoint}/fetch-json", + json={ + "url": "https://cian.ru/api/x", + "source": "cian", + "method": "GET", + "headers": {}, + "body": None, + "origin": None, + }, + ) + + +@pytest.mark.asyncio +async def test_fetch_json_retries_once_on_transport_error() -> None: + """TransportError первый раз → один retry → успешный второй вызов.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + ok_resp = _make_ok_json_response(status=200, body="retry-ok") + connect_err = httpx.ConnectError("Connection refused") + ms = _mock_settings() + + with patch("app.core.config.settings", ms): + fetcher = BrowserFetcher() + async with fetcher: + assert fetcher._client is not None + post_mock = AsyncMock(side_effect=[connect_err, ok_resp]) + fetcher._client.post = post_mock # type: ignore[method-assign] + + with patch("app.services.scrapers.browser_fetcher.asyncio.sleep", AsyncMock()): + result = await fetcher.fetch_json("https://avito.ru/api/retry") + + assert result == {"status": 200, "body": "retry-ok"} + assert post_mock.await_count == 2 + + +@pytest.mark.asyncio +async def test_fetch_json_raises_after_two_http_errors() -> None: + """Оба вызова /fetch-json кидают HTTPError → исключение пробрасывается.""" + from app.services.scrapers.browser_fetcher import BrowserFetcher + + error_resp_1 = _make_error_response(500) + error_resp_2 = _make_error_response(500) + ms = _mock_settings() + + with patch("app.core.config.settings", ms): + fetcher = BrowserFetcher() + async with fetcher: + assert fetcher._client is not None + post_mock = AsyncMock(side_effect=[error_resp_1, error_resp_2]) + fetcher._client.post = post_mock # type: ignore[method-assign] + + with patch("app.services.scrapers.browser_fetcher.asyncio.sleep", AsyncMock()): + with pytest.raises(httpx.HTTPStatusError): + await fetcher.fetch_json("https://avito.ru/api/fail") + + assert post_mock.await_count == 2 + + # ── retry при HTTPError ──────────────────────────────────────────────────────── diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 7134dcab..4540334c 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -4,9 +4,10 @@ Он запускает camoufox **локально** внутри контейнера (AsyncCamoufox) и экспонирует простой HTTP API на базе aiohttp: - GET /health → {"status": "ok", "browsers": {"avito": bool, ...}} - POST /fetch → {"url": "..."} → {"html": "..."} - POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]} + GET /health → {"status": "ok", "browsers": {"avito": bool, ...}} + POST /fetch → {"url": "..."} → {"html": "..."} + POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"} + POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]} Такой подход выбран потому, что ``camoufox.server.launch_server`` (Playwright WS-сервер) несовместим с современными версиями playwright (1.45–1.60): @@ -564,6 +565,70 @@ async def fetch_handler(request: web.Request) -> web.Response: return web.json_response({"html": html}) +async def fetch_json_handler(request: web.Request) -> web.Response: + """POST /fetch-json {"url","method","headers","body","origin","source"} → {"status","body"} + + Выполняет in-page ``fetch()`` ИЗ ТЁПЛОЙ страницы браузера поставщика: камуфокс + сначала переходит на ``origin`` (same-origin якорь), затем дёргает ``fetch(url)`` + с реальным фингерпринтом + контекст-cookies этого инстанса + per-provider прокси. + Возвращает JSON/текст ответа как ``{"status": int, "body": str}``. + + Чисто аддитивный путь (#915 Stage 2) — никто из прод-флоу пока не вызывает. + Делит тот же per-provider браузер/лок что и /fetch (поставщик по host URL или + явному body["provider"]/["source"]). Берётся ТОЛЬКО лок этого поставщика → + разные поставщики работают параллельно, внутри поставщика — строго ≤1 операция. + """ + try: + body = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + + url: str | None = body.get("url") + if not url: + return web.json_response({"error": "missing 'url' field"}, status=400) + + method: str = body.get("method") or "GET" + headers: dict = body.get("headers") or {} + req_body = body.get("body") # None | str (caller сериализует через json.dumps) + # origin — same-origin страница, на которую перейдёт камуфокс перед fetch'ем. + # Явный body["origin"] имеет приоритет; иначе выводим f"{scheme}://{host}/" из url. + origin: str | None = body.get("origin") + if not origin: + parsed = urlparse(url) + origin = f"{parsed.scheme}://{parsed.netloc}/" + + provider = _resolve_provider(body, url) + + lock = await _lock_for(provider) + async with lock: + # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал). + # Lazy-попытка, иначе 503 — без прокси/браузера не фетчим. + if not await _ensure_browser(provider): + logger.warning( + "tradein-browser[%s]: /fetch-json 503 — браузер недоступен (proxy may be down)", + provider, + ) + return web.json_response( + {"error": "browser unavailable (proxy may be down)"}, status=503 + ) + + try: + result = await _do_fetch_json( + provider, url, method=method, headers=headers, body=req_body, origin=origin + ) + except Exception as exc: + logger.error( + "tradein-browser[%s]: fetch-json error url=%r: %s: %s", + provider, + url, + type(exc).__name__, + exc, + ) + return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500) + + return web.json_response({"status": result["status"], "body": result["body"]}) + + async def _apply_resource_block(page: object) -> None: """Навешивает route-interception, блокирующую тяжёлые под-ресурсы (font/media). @@ -685,6 +750,107 @@ async def _fetch_once(provider: str, url: str) -> str: return html +async def _do_fetch_json( + provider: str, + url: str, + *, + method: str, + headers: dict, + body: object, + origin: str, +) -> dict: + """Одна попытка in-page fetch'а; при краше браузера — relaunch и один retry. + + Зеркалит _do_fetch: caller держит _locks[provider] (нет параллельных страниц на + этом инстансе), поэтому relaunch безопасен — никого не рвёт из-под живых страниц. + """ + try: + return await _fetch_json_once( + provider, url, method=method, headers=headers, body=body, origin=origin + ) + except Exception as exc: + if _is_browser_crash(exc): + logger.warning( + "tradein-browser[%s]: краш браузера (%s), перезапуск + retry fetch-json: %s", + provider, + type(exc).__name__, + url, + ) + await _relaunch_browser(provider) + if _browsers.get(provider) is None: + raise + return await _fetch_json_once( + provider, url, method=method, headers=headers, body=body, origin=origin + ) + raise + + +async def _fetch_json_once( + provider: str, + url: str, + *, + method: str, + headers: dict, + body: object, + origin: str, +) -> dict: + """Переходит на origin (same-origin якорь) и выполняет in-page fetch(url). + + Зеркалит _fetch_once по жизненному циклу страницы. Навигация идёт на ``origin`` + (а не на ``url``), чтобы in-page fetch был same-origin и нёс контекст-cookies + + реальный фингерпринт инстанса. Возвращает {"status": int, "body": str}. + + Caller держит _locks[provider], поэтому страницы на этом инстансе не + параллелятся — recycle через _relaunch_browser безопасен прямо здесь. + """ + browser = _browsers.get(provider) + assert browser is not None, "browser not launched" + + page = await browser.new_page() # type: ignore[attr-defined] + try: + await _apply_resource_block(page) + await _pace_provider(provider) + await page.goto(origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded") # type: ignore[attr-defined] + # БЕЗ полного BROWSER_WAIT_MS: нам нужен лишь origin-контекст (cookies + + # same-origin scope для fetch), а не отрендеренные listings. Короткой паузы + # хватает, чтобы страница инициализировалась перед in-page fetch'ем. + await page.wait_for_timeout(500) # type: ignore[attr-defined] + result: dict = await page.evaluate( # type: ignore[attr-defined] + """async ({url, method, headers, body}) => { + const resp = await fetch(url, { + method: method || 'GET', + headers: headers || {}, + body: (body !== null && body !== undefined) ? body : undefined, + credentials: 'include', + }); + const text = await resp.text(); + return { status: resp.status, body: text }; + }""", + {"url": url, "method": method, "headers": headers or {}, "body": body}, + ) + finally: + await page.close() # type: ignore[attr-defined] + + _page_counters[provider] = _page_counters.get(provider, 0) + 1 + logger.debug( + "tradein-browser[%s]: fetch-json OK url=%r status=%s pages_since_launch=%d", + provider, + url, + result.get("status"), + _page_counters[provider], + ) + + if _page_counters[provider] >= BROWSER_RECYCLE_PAGES: + logger.info( + "tradein-browser[%s]: recycle threshold (%d) достигнут, перезапуск браузера", + provider, + BROWSER_RECYCLE_PAGES, + ) + await _relaunch_browser(provider) + + return result + + def _is_browser_crash(exc: BaseException) -> bool: """Проверяет является ли исключение признаком краша / разрыва браузера.""" cls_name = type(exc).__name__ @@ -975,6 +1141,7 @@ def build_app() -> web.Application: app.on_cleanup.append(_on_cleanup) app.router.add_get("/health", health_handler) app.router.add_post("/fetch", fetch_handler) + app.router.add_post("/fetch-json", fetch_json_handler) app.router.add_post("/login", login_handler) app.router.add_get("/pacing", pacing_get_handler) app.router.add_put("/pacing", pacing_put_handler) diff --git a/tradein-mvp/browser/test_server_fetch_json.py b/tradein-mvp/browser/test_server_fetch_json.py new file mode 100644 index 00000000..0fd6ae2d --- /dev/null +++ b/tradein-mvp/browser/test_server_fetch_json.py @@ -0,0 +1,268 @@ +"""test_server_fetch_json.py — юниты для аддитивного /fetch-json (#915 Stage 2). + +Проверяет новый in-page fetch путь, НЕ трогая существующий /fetch: + 1. fetch_json_handler возвращает {"status","body"} из page.evaluate; + 2. 400 при отсутствии url; + 3. 503 когда _ensure_browser отдаёт False (прокси/браузер недоступны); + 4. навигация идёт на origin (same-origin якорь), не на url; + 5. page закрывается после in-page fetch (finally). + +camoufox НЕ запускается: _ensure_browser мокается / провайдер-браузер подделан +фейковой page с AsyncMock на evaluate. Хендлер вызывается напрямую +(make_mocked_request не поднимает сокет) — как в test_server_smoke.py. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_fetch_json.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from aiohttp.test_utils import make_mocked_request + +# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути. +_SERVER_PATH = Path(__file__).resolve().parent / "server.py" +_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) +assert _spec is not None and _spec.loader is not None +server = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(server) + + +def _json_body(response: Any) -> dict[str, Any]: + """Достаёт JSON-тело из aiohttp Response (body — bytes после json_response).""" + return json.loads(response.body.decode()) + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + """Чистое per-provider состояние + инициализированный _locks_guard на каждый тест.""" + monkeypatch.setattr(server, "_browsers", {}) + monkeypatch.setattr(server, "_browser_cms", {}) + monkeypatch.setattr(server, "_page_counters", {}) + monkeypatch.setattr(server, "_locks", {}) + monkeypatch.setattr(server, "_retry_tasks", {}) + monkeypatch.setattr(server, "_last_goto_at", {}) + monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) + + +async def _coro(value: Any) -> Any: + """Хелпер: оборачивает значение в awaitable для подмены request.json().""" + return value + + +# ── фейковые page/browser с AsyncMock на evaluate ───────────────────────────────── + + +class _FakePage: + """Поддельная page: фиксирует goto-цель, отдаёт фиктивный результат evaluate.""" + + def __init__(self, evaluate_result: dict[str, Any]) -> None: + self.goto_urls: list[str] = [] + self.closed = 0 + # evaluate — AsyncMock, чтобы проверять как сам результат, так и аргументы. + self.evaluate = AsyncMock(return_value=evaluate_result) + + async def route(self, pattern: str, handler: Any) -> None: + return None + + async def goto(self, url: str, **kwargs: Any) -> None: + self.goto_urls.append(url) + + async def wait_for_timeout(self, ms: int) -> None: + return None + + async def close(self) -> None: + self.closed += 1 + + +class _FakeBrowser: + def __init__(self, page: _FakePage) -> None: + self._page = page + self.opened = 0 + + async def new_page(self) -> _FakePage: + self.opened += 1 + return self._page + + +def _make_request(body: dict[str, Any]) -> Any: + request = make_mocked_request("POST", "/fetch-json") + request.json = lambda: _coro(body) # type: ignore[method-assign] + return request + + +# ── happy path ──────────────────────────────────────────────────────────────────── + + +def test_fetch_json_returns_evaluate_result(monkeypatch: pytest.MonkeyPatch) -> None: + """fetch_json_handler возвращает {"status","body"} ровно как отдал page.evaluate.""" + page = _FakePage({"status": 200, "body": '{"ok": true}'}) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + async def _ensure(provider: str) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + response = asyncio.run( + server.fetch_json_handler( + _make_request({"url": "https://www.avito.ru/api/x", "source": "avito"}) + ) + ) + assert response.status == 200 + body = _json_body(response) + assert body == {"status": 200, "body": '{"ok": true}'} + # page.evaluate был вызван ровно один раз с url+method+headers+body. + page.evaluate.assert_awaited_once() + call_args = page.evaluate.await_args + assert call_args.args[1]["url"] == "https://www.avito.ru/api/x" + assert call_args.args[1]["method"] == "GET" + # Страница открылась и закрылась. + assert page.closed == 1 + + +def test_fetch_json_navigates_to_origin_not_url(monkeypatch: pytest.MonkeyPatch) -> None: + """Навигация идёт на origin (same-origin якорь), а fetch — уже на url.""" + page = _FakePage({"status": 200, "body": "ok"}) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + async def _ensure(provider: str) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + asyncio.run( + server.fetch_json_handler( + _make_request({"url": "https://www.avito.ru/api/listings?q=1", "source": "avito"}) + ) + ) + # Дефолтный origin выводится из url: scheme://netloc/. + assert page.goto_urls == ["https://www.avito.ru/"] + + +def test_fetch_json_uses_explicit_origin(monkeypatch: pytest.MonkeyPatch) -> None: + """Явный body["origin"] перебивает выведенный из url.""" + page = _FakePage({"status": 200, "body": "ok"}) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + async def _ensure(provider: str) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + asyncio.run( + server.fetch_json_handler( + _make_request( + { + "url": "https://www.avito.ru/api/x", + "origin": "https://www.avito.ru/moskva/kvartiry", + "source": "avito", + } + ) + ) + ) + assert page.goto_urls == ["https://www.avito.ru/moskva/kvartiry"] + + +def test_fetch_json_passes_post_method_and_body(monkeypatch: pytest.MonkeyPatch) -> None: + """method/headers/body прокидываются в page.evaluate без искажений.""" + page = _FakePage({"status": 201, "body": "{}"}) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + async def _ensure(provider: str) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + asyncio.run( + server.fetch_json_handler( + _make_request( + { + "url": "https://www.avito.ru/api/x", + "method": "POST", + "headers": {"content-type": "application/json"}, + "body": '{"a": 1}', + "source": "avito", + } + ) + ) + ) + payload = page.evaluate.await_args.args[1] + assert payload["method"] == "POST" + assert payload["headers"] == {"content-type": "application/json"} + assert payload["body"] == '{"a": 1}' + + +# ── валидация / resilience ──────────────────────────────────────────────────────── + + +def test_fetch_json_400_on_missing_url() -> None: + """Отсутствие url → 400 (без обращения к браузеру).""" + response = asyncio.run(server.fetch_json_handler(_make_request({"source": "avito"}))) + assert response.status == 400 + assert "missing 'url'" in _json_body(response)["error"] + + +def test_fetch_json_400_on_invalid_json() -> None: + """Невалидное JSON-тело → 400.""" + request = make_mocked_request("POST", "/fetch-json") + + async def _raise() -> Any: + raise ValueError("bad json") + + request.json = _raise # type: ignore[method-assign] + response = asyncio.run(server.fetch_json_handler(request)) + assert response.status == 400 + assert "invalid JSON body" in _json_body(response)["error"] + + +def test_fetch_json_503_when_browser_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + """/fetch-json отдаёт 503 (не 500/краш) когда инстанс поставщика не поднят.""" + + async def _no_ensure(provider: str) -> bool: + return False # прокси всё ещё недоступен → браузер остаётся None + + monkeypatch.setattr(server, "_ensure_browser", _no_ensure) + + response = asyncio.run( + server.fetch_json_handler( + _make_request({"url": "https://www.avito.ru/api/x", "source": "avito"}) + ) + ) + assert response.status == 503 + assert "browser unavailable" in _json_body(response)["error"] + + +def test_fetch_json_500_on_evaluate_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Ошибка page.evaluate (не краш браузера) → 500 с типом исключения.""" + page = _FakePage({"status": 200, "body": "ok"}) + page.evaluate = AsyncMock(side_effect=RuntimeError("eval boom")) + server._browsers["avito"] = _FakeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + async def _ensure(provider: str) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + + response = asyncio.run( + server.fetch_json_handler( + _make_request({"url": "https://www.avito.ru/api/x", "source": "avito"}) + ) + ) + assert response.status == 500 + assert "RuntimeError" in _json_body(response)["error"] + # Даже при ошибке evaluate страница должна закрыться (finally). + assert page.closed == 1