"""Tests for BrowserFetcher (#884/#905, connect-mode).
Полностью замоканы: не требуют установленного Firefox, camoufox или реального
WS-сервера. Покрывают:
- fetch(): new_page → goto → wait_for_timeout → content → close
- recycle: счётчик страниц → reconnect при достижении порога
- crash-recovery: TargetClosedError → reconnect → retry
- page counter сбрасывается после reconnect
- non-crash exception пробрасывается без перехвата
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# ── helpers ────────────────────────────────────────────────────────────────────
def _mock_settings(
proxy_url: str | None = None,
recycle_pages: int = 15,
nav_timeout_ms: int = 60000,
wait_ms: int = 2500,
ws_endpoint: str = "ws://fake-browser:3000/playwright",
) -> SimpleNamespace:
return SimpleNamespace(
scraper_proxy_url=proxy_url,
browser_recycle_pages=recycle_pages,
browser_nav_timeout_ms=nav_timeout_ms,
browser_wait_ms=wait_ms,
browser_ws_endpoint=ws_endpoint,
)
def _make_mock_page(html: str = "ok") -> MagicMock:
"""Создаёт мок страницы playwright с нужными async-методами."""
page = MagicMock()
page.goto = AsyncMock()
page.wait_for_timeout = AsyncMock()
page.content = AsyncMock(return_value=html)
page.close = AsyncMock()
return page
def _make_playwright_stack(
page: MagicMock | None = None,
html: str = "ok",
) -> tuple[MagicMock, MagicMock, MagicMock]:
"""Создаёт полную mock-цепочку playwright: pw_cm → pw → browser → page.
Returns:
(pw_cm, pw_mock, browser_mock)
"""
if page is None:
page = _make_mock_page(html=html)
browser_mock = MagicMock()
browser_mock.new_page = AsyncMock(return_value=page)
browser_mock.close = AsyncMock()
firefox_mock = MagicMock()
firefox_mock.connect = AsyncMock(return_value=browser_mock)
pw_mock = MagicMock()
pw_mock.firefox = firefox_mock
pw_mock.stop = AsyncMock()
async def _start() -> MagicMock:
return pw_mock
pw_cm = MagicMock()
pw_cm.start = _start
return pw_cm, pw_mock, browser_mock
# ── fetch(): new_page → goto → content → close ────────────────────────────────
@pytest.mark.asyncio
async def test_fetch_calls_page_lifecycle():
"""fetch() вызывает new_page, goto, wait_for_timeout, content, close."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
expected_html = "
test"
page = _make_mock_page(html=expected_html)
pw_cm, _pw, browser = _make_playwright_stack(page=page)
ms = _mock_settings(recycle_pages=15, nav_timeout_ms=60000, wait_ms=2500)
with patch("app.core.config.settings", ms):
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
fetcher = BrowserFetcher()
async with fetcher:
result = await fetcher.fetch("https://example.com")
assert result == expected_html
browser.new_page.assert_called_once()
page.goto.assert_called_once_with(
"https://example.com", timeout=60000, wait_until="domcontentloaded"
)
page.wait_for_timeout.assert_called_once_with(2500)
page.content.assert_called_once()
page.close.assert_called_once()
@pytest.mark.asyncio
async def test_fetch_returns_html_string():
"""fetch() возвращает строку HTML от page.content()."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
html = "Avito"
pw_cm, _pw, _browser = _make_playwright_stack(html=html)
ms = _mock_settings()
with patch("app.core.config.settings", ms):
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
fetcher = BrowserFetcher()
async with fetcher:
result = await fetcher.fetch("https://avito.ru/test")
assert result == html
assert isinstance(result, str)
# ── recycle: reconnect at threshold ───────────────────────────────────────────
@pytest.mark.asyncio
async def test_recycle_triggers_reconnect_at_threshold():
"""После recycle_pages страниц выполняется переподключение (_reconnect)."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
recycle_after = 3
pw_cm, _pw, _browser = _make_playwright_stack()
ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
reconnect_count = 0
original_reconnect = BrowserFetcher._reconnect
async def counting_reconnect(self: BrowserFetcher) -> None:
nonlocal reconnect_count
reconnect_count += 1
await original_reconnect(self)
with patch("app.core.config.settings", ms):
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
with patch.object(BrowserFetcher, "_reconnect", counting_reconnect):
fetcher = BrowserFetcher()
async with fetcher:
assert reconnect_count == 0
for _ in range(recycle_after):
await fetcher.fetch("https://example.com")
# Третья страница триггерит recycle
assert reconnect_count == 1
@pytest.mark.asyncio
async def test_page_counter_resets_after_reconnect():
"""После recycle _page_count сбрасывается в 0."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
recycle_after = 2
pw_cm, _pw, _browser = _make_playwright_stack()
ms = _mock_settings(recycle_pages=recycle_after, wait_ms=0)
with patch("app.core.config.settings", ms):
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
fetcher = BrowserFetcher()
async with fetcher:
for _ in range(recycle_after):
await fetcher.fetch("https://example.com")
# После recycle счётчик сброшен
assert fetcher._page_count == 0
# ── crash-recovery ─────────────────────────────────────────────────────────────
class _FakeTargetClosedError(Exception):
"""Имитирует playwright TargetClosedError (проверка по имени класса)."""
_FakeTargetClosedError.__name__ = "TargetClosedError"
_FakeTargetClosedError.__qualname__ = "TargetClosedError"
@pytest.mark.asyncio
async def test_fetch_retries_once_on_target_closed_error():
"""fetch() при TargetClosedError переподключается и повторяет запрос."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
html = "retry-ok"
# Первая страница кидает краш, вторая отвечает нормально
crash_page = MagicMock()
crash_page.goto = AsyncMock(side_effect=_FakeTargetClosedError("target closed"))
crash_page.close = AsyncMock()
ok_page = _make_mock_page(html=html)
browser_mock = MagicMock()
browser_mock.close = AsyncMock()
call_num = 0
async def new_page_side_effect() -> MagicMock:
nonlocal call_num
call_num += 1
return crash_page if call_num == 1 else ok_page
browser_mock.new_page = AsyncMock(side_effect=new_page_side_effect)
firefox_mock = MagicMock()
firefox_mock.connect = AsyncMock(return_value=browser_mock)
pw_mock = MagicMock()
pw_mock.firefox = firefox_mock
pw_mock.stop = AsyncMock()
async def _start() -> MagicMock:
return pw_mock
pw_cm = MagicMock()
pw_cm.start = _start
ms = _mock_settings(wait_ms=0)
with patch("app.core.config.settings", ms):
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
fetcher = BrowserFetcher()
async with fetcher:
result = await fetcher.fetch("https://avito.ru/page")
assert result == html
assert call_num == 2
# connect вызывался дважды: при __aenter__ + при reconnect
assert firefox_mock.connect.await_count == 2
@pytest.mark.asyncio
async def test_fetch_propagates_non_crash_exception():
"""fetch() не подавляет обычные (не-crash) исключения."""
from app.services.scrapers.browser_fetcher import BrowserFetcher
page = MagicMock()
page.goto = AsyncMock(side_effect=ValueError("unexpected parse error"))
page.close = AsyncMock()
pw_cm, _pw, _browser = _make_playwright_stack(page=page)
ms = _mock_settings(wait_ms=0)
with patch("app.core.config.settings", ms):
with patch("playwright.async_api.async_playwright", return_value=pw_cm):
fetcher = BrowserFetcher()
async with fetcher:
with pytest.raises(ValueError, match="unexpected parse error"):
await fetcher.fetch("https://avito.ru/bad")