All checks were successful
Deploy Trade-In / build-browser (push) Successful in 2m13s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 2m29s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
367 lines
15 KiB
Python
367 lines
15 KiB
Python
"""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.waits: list[int] = [] # записанные wait_for_timeout(ms) — settle-проверка #1917
|
||
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:
|
||
self.waits.append(ms)
|
||
|
||
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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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}'
|
||
|
||
|
||
# ── settle + in-page retry (#1917) ─────────────────────────────────────────────────
|
||
|
||
|
||
def test_fetch_json_settle_uses_configured_ms(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Settle перед in-page fetch = FETCH_JSON_SETTLE_MS (а не хардкод 500)."""
|
||
page = _FakePage({"status": 200, "body": "ok"})
|
||
server._browsers["avito"] = _FakeBrowser(page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
monkeypatch.setattr(server, "FETCH_JSON_SETTLE_MS", 1777)
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
|
||
asyncio.run(
|
||
server.fetch_json_handler(
|
||
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||
)
|
||
)
|
||
assert page.waits == [1777]
|
||
|
||
|
||
def test_fetch_json_passes_inpage_retry_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""В page.evaluate прокидываются retries/retryDelayMs из конфигурации (#1917)."""
|
||
page = _FakePage({"status": 200, "body": "ok", "attempts": 1})
|
||
server._browsers["avito"] = _FakeBrowser(page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
monkeypatch.setattr(server, "FETCH_JSON_INPAGE_RETRIES", 2)
|
||
monkeypatch.setattr(server, "FETCH_JSON_RETRY_DELAY_MS", 654)
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
|
||
asyncio.run(
|
||
server.fetch_json_handler(
|
||
_make_request({"url": "https://www.avito.ru/api/x", "source": "avito"})
|
||
)
|
||
)
|
||
payload = page.evaluate.await_args.args[1]
|
||
assert payload["retries"] == 2
|
||
assert payload["retryDelayMs"] == 654
|
||
|
||
|
||
# ── валидация / 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, proxy_override: str | None = None) -> 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, proxy_override: str | None = None) -> 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
|
||
|
||
|
||
# ── crash-retry (#1915 deep-review nice-to-have) ──────────────────────────────────
|
||
|
||
|
||
class TargetClosedError(Exception):
|
||
"""Имя класса распознаётся _is_browser_crash (по type(exc).__name__) как краш."""
|
||
|
||
|
||
def test_do_fetch_json_relaunch_on_browser_crash(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""_do_fetch_json: первый _fetch_json_once крашит браузер → relaunch + второй успех.
|
||
|
||
Проверяем именно crash-retry ветку: первый page.evaluate бросает
|
||
TargetClosedError (его type().__name__ ловит _is_browser_crash), что триггерит
|
||
_relaunch_browser + повторный _fetch_json_once на свежей странице. Результат
|
||
должен прийти из ВТОРОЙ попытки.
|
||
"""
|
||
crashing_page = _FakePage({"status": 0, "body": ""})
|
||
crashing_page.evaluate = AsyncMock(side_effect=TargetClosedError("Target page crashed"))
|
||
healthy_page = _FakePage({"status": 200, "body": '{"recovered": true}'})
|
||
|
||
# Первый инстанс — крашащийся; после relaunch подставляем здоровый.
|
||
server._browsers["avito"] = _FakeBrowser(crashing_page)
|
||
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||
|
||
relaunched: list[str] = []
|
||
|
||
async def _fake_relaunch(provider: str) -> None:
|
||
relaunched.append(provider)
|
||
server._browsers[provider] = _FakeBrowser(healthy_page)
|
||
|
||
monkeypatch.setattr(server, "_relaunch_browser", _fake_relaunch)
|
||
|
||
result = asyncio.run(
|
||
server._do_fetch_json(
|
||
"avito",
|
||
"https://www.avito.ru/api/x",
|
||
method="GET",
|
||
headers={},
|
||
body=None,
|
||
origin="https://www.avito.ru/",
|
||
)
|
||
)
|
||
|
||
# Результат — из ВТОРОЙ (после relaunch) попытки, не из крашнувшей.
|
||
assert result == {"status": 200, "body": '{"recovered": true}'}
|
||
assert relaunched == ["avito"]
|
||
# Обе страницы успели открыться/закрыться (crashing — в finally, healthy — норм).
|
||
crashing_page.evaluate.assert_awaited_once()
|
||
healthy_page.evaluate.assert_awaited_once()
|
||
assert crashing_page.closed == 1
|
||
assert healthy_page.closed == 1
|