"""Ретрай навигации у локального сборщика `scripts/local-avito-msk/collect.py`. Сборщик лежит вне пакета бэкенда и до сих пор не имел тестов вообще — при том что уже дважды ронял многочасовой прогон на ошибке, которая отказом площадки не была. Поэтому файл подгружается по пути; нет его на месте (урезанный чекаут) — тест пропускается, а не краснеет. Караулим ровно границу «наше или ихнее»: * сетевой сбой САМОЙ МАШИНЫ (`net::ERR_*` из списка) — ждать и повторять; * таймаут навигации — прежние три попытки и `nav_timeout`; * любая другая ошибка навигации — поднимать, потому что тихий отказ площадки выглядит точно так же, а молча проглоченный он превращается в пустой прогон. """ from __future__ import annotations import asyncio import importlib.util import os import sys from pathlib import Path from typing import Any import pytest _COLLECTOR = ( Path(__file__).resolve().parents[2] / "scripts" / "local-avito-msk" / "collect.py" ) @pytest.fixture(scope="module") def collect() -> Any: if not _COLLECTOR.exists(): # pragma: no cover — урезанный чекаут pytest.skip(f"нет {_COLLECTOR}") # Сборщик тянет scraper_kit, а тот — Settings с обязательным database_url. # Ставим тот же фиктивный DSN, что и соседние тесты: в сеть и в БД этот # модуль не ходит, значение нужно только чтобы импорт состоялся. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") spec = importlib.util.spec_from_file_location("msk_collect_under_test", _COLLECTOR) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module # dataclass резолвит модуль через sys.modules spec.loader.exec_module(module) return module class _PwError(Exception): pass class _PwTimeoutError(_PwError): pass class _Page: """Отдаёт заготовленный сценарий: исключение или успешный ответ.""" def __init__(self, script: list[BaseException | None]) -> None: self.script = list(script) self.calls = 0 async def goto(self, url: str, **_kw: Any) -> str: self.calls += 1 exc = self.script.pop(0) if exc is not None: raise exc return "RESP" async def content(self) -> str: return "" @pytest.fixture() def loader(collect: Any, monkeypatch: pytest.MonkeyPatch) -> Any: monkeypatch.setattr(collect, "PlaywrightError", _PwError) monkeypatch.setattr(collect, "PlaywrightTimeoutError", _PwTimeoutError) async def _no_sleep(_seconds: float) -> None: return None monkeypatch.setattr(collect.asyncio, "sleep", _no_sleep) def _make(page: _Page) -> Any: obj = collect.Loader.__new__(collect.Loader) obj._page = page obj._adapter = None return obj return _make @pytest.mark.parametrize( "message", ["Page.goto: net::ERR_NETWORK_CHANGED at https://x", "net::ERR_CONNECTION_RESET"], ) def test_transient_network_errors_are_recognised(collect: Any, message: str) -> None: assert collect._is_transient_net_error(Exception(message)) @pytest.mark.parametrize( "message", ["Page.goto: net::ERR_CERT_AUTHORITY_INVALID", "Доступ ограничен", ""], ) def test_other_errors_are_not_network(collect: Any, message: str) -> None: assert not collect._is_transient_net_error(Exception(message)) def test_network_blip_is_waited_out(loader: Any) -> None: """Два обрыва подряд — не повод терять прогон: третья попытка отдаёт ответ.""" page = _Page( [_PwError("net::ERR_NETWORK_CHANGED"), _PwError("net::ERR_NETWORK_CHANGED"), None] ) assert asyncio.run(loader(page)._goto("u")) == "RESP" assert page.calls == 3 def test_network_gone_for_good_stops_with_its_own_reason(collect: Any, loader: Any) -> None: page = _Page([_PwError("net::ERR_NETWORK_CHANGED")] * 6) with pytest.raises(collect.Blocked) as caught: asyncio.run(loader(page)._goto("u")) assert str(caught.value) == "nav_network" assert page.calls == 5 def test_unknown_navigation_error_is_raised(loader: Any) -> None: """Незнакомую ошибку глотать нельзя — под ней прячется настоящий отказ.""" page = _Page([_PwError("net::ERR_CERT_AUTHORITY_INVALID")]) with pytest.raises(_PwError): asyncio.run(loader(page)._goto("u")) def test_timeout_path_is_unchanged(collect: Any, loader: Any) -> None: """Сетевой счётчик отдельный: бюджет попыток по таймауту остался прежним.""" page = _Page([_PwTimeoutError("t")] * 4) with pytest.raises(collect.Blocked) as caught: asyncio.run(loader(page)._goto("u")) assert str(caught.value) == "nav_timeout" assert page.calls == 3