All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m18s
Deploy Trade-In / test (push) Successful in 4m4s
Deploy Trade-In / build-backend (push) Successful in 1m36s
Deploy Trade-In / deploy (push) Successful in 2m0s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
571 lines
26 KiB
Python
571 lines
26 KiB
Python
"""test_server.py — юниты per-provider concurrency для tradein-browser (#1793).
|
||
|
||
Проверяет ядро per-provider модели:
|
||
1. _provider_for_url — корректный маппинг host → provider (avito/cian/yandex/generic);
|
||
2. _provider_proxy — SCRAPER_PROXY_URL, общий для всех поставщиков (#2616 шаг 2:
|
||
per-provider BROWSER_PROXY_*/legacy *_PROXY_URL сняты);
|
||
3. _resolve_provider — явный body["provider"] перебивает host, иначе host;
|
||
4. конкурентность: два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи),
|
||
два к ОДНОМУ — сериализованы (один лок). Через _do_fetch с asyncio.Event и
|
||
замером перекрытия по времени.
|
||
|
||
camoufox НЕ запускается: _ensure_browser / _do_fetch мокаются. Хендлер вызывается
|
||
напрямую (make_mocked_request не поднимает сокет).
|
||
|
||
Запуск (из tradein-mvp/browser/)::
|
||
|
||
python -m pytest test_server.py -q
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
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]:
|
||
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())
|
||
|
||
|
||
# ── _provider_for_url ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_provider_for_url_avito() -> None:
|
||
assert server._provider_for_url("https://www.avito.ru/moskva/kvartiry") == "avito"
|
||
assert server._provider_for_url("https://m.avito.ru/item/123") == "avito"
|
||
|
||
|
||
def test_provider_for_url_cian() -> None:
|
||
assert server._provider_for_url("https://www.cian.ru/sale/flat/1/") == "cian"
|
||
assert server._provider_for_url("https://spb.cian.ru/x") == "cian"
|
||
|
||
|
||
def test_provider_for_url_yandex() -> None:
|
||
assert server._provider_for_url("https://realty.yandex.ru/offer/1/") == "yandex"
|
||
|
||
|
||
def test_provider_for_url_generic_fallback() -> None:
|
||
assert server._provider_for_url("https://example.com/x") == "generic"
|
||
assert server._provider_for_url("https://domclick.ru/x") == "generic"
|
||
# Битый / пустой URL — без хоста → generic, не падаем.
|
||
assert server._provider_for_url("not-a-url") == "generic"
|
||
assert server._provider_for_url("") == "generic"
|
||
|
||
|
||
# ── _provider_proxy ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_provider_proxy_shared_across_providers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""#2616 шаг 2: SCRAPER_PROXY_URL — единственный источник, общий для всех."""
|
||
monkeypatch.setenv("SCRAPER_PROXY_URL", "http://u:p@shared-proxy:8080")
|
||
assert server._provider_proxy("avito") == "http://u:p@shared-proxy:8080"
|
||
assert server._provider_proxy("cian") == "http://u:p@shared-proxy:8080"
|
||
assert server._provider_proxy("yandex") == "http://u:p@shared-proxy:8080"
|
||
assert server._provider_proxy("generic") == "http://u:p@shared-proxy:8080"
|
||
|
||
|
||
def test_provider_proxy_none_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""SCRAPER_PROXY_URL не задан → None (direct, как одиночный браузер без прокси)."""
|
||
monkeypatch.delenv("SCRAPER_PROXY_URL", raising=False)
|
||
assert server._provider_proxy("cian") is None
|
||
|
||
|
||
# ── _resolve_provider ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_resolve_provider_by_host() -> None:
|
||
assert server._resolve_provider({}, "https://www.avito.ru/x") == "avito"
|
||
assert server._resolve_provider({}, "https://www.cian.ru/x") == "cian"
|
||
|
||
|
||
def test_resolve_provider_explicit_overrides_host() -> None:
|
||
"""Явный валидный body["provider"] перебивает host."""
|
||
assert server._resolve_provider({"provider": "yandex"}, "https://www.avito.ru/x") == "yandex"
|
||
|
||
|
||
def test_resolve_provider_invalid_explicit_falls_back_to_host() -> None:
|
||
"""Невалидный body["provider"] игнорируется → берём по host."""
|
||
assert server._resolve_provider({"provider": "bogus"}, "https://www.cian.ru/x") == "cian"
|
||
assert server._resolve_provider({"provider": 123}, "https://www.cian.ru/x") == "cian"
|
||
|
||
|
||
def test_resolve_provider_uses_source_field() -> None:
|
||
"""browser_fetcher шлёт {"url","source"} — explicit берётся и из "source"."""
|
||
assert server._resolve_provider({"source": "avito"}, "https://example.com/x") == "avito"
|
||
assert server._resolve_provider({"source": "yandex"}, "https://www.avito.ru/x") == "yandex"
|
||
|
||
|
||
def test_resolve_provider_domclick_source_to_generic() -> None:
|
||
"""domclick scraper шлёт source='domklik' (нет в PROVIDERS) + хост domclick.ru.
|
||
|
||
'domklik' не валиден → host-detect domclick.ru → generic. Намеренно: 'domklik'
|
||
не входит в PROVIDERS (нет выделенного instance).
|
||
"""
|
||
assert (
|
||
server._resolve_provider({"source": "domklik"}, "https://domclick.ru/x") == "generic"
|
||
)
|
||
|
||
|
||
# ── _lock_for: один лок на поставщика ────────────────────────────────────────────
|
||
|
||
|
||
def test_lock_for_is_stable_per_provider() -> None:
|
||
"""_lock_for возвращает один и тот же объект-лок для поставщика, разные — для разных."""
|
||
|
||
async def _run() -> tuple[Any, Any, Any]:
|
||
a1 = await server._lock_for("avito")
|
||
a2 = await server._lock_for("avito")
|
||
c1 = await server._lock_for("cian")
|
||
return a1, a2, c1
|
||
|
||
a1, a2, c1 = asyncio.run(_run())
|
||
assert a1 is a2, "лок одного поставщика стабилен"
|
||
assert a1 is not c1, "локи разных поставщиков различны"
|
||
|
||
|
||
# ── concurrency: параллель между поставщиками, сериализация внутри ───────────────
|
||
|
||
|
||
def _make_fetch_request(url: str) -> Any:
|
||
request = make_mocked_request("POST", "/fetch")
|
||
request.json = lambda: _coro({"url": url}) # type: ignore[method-assign]
|
||
return request
|
||
|
||
|
||
class _OverlapProbe:
|
||
"""Мок _do_fetch: меряет максимальное число одновременно активных fetch per-provider."""
|
||
|
||
def __init__(self, hold: float = 0.05) -> None:
|
||
self.hold = hold
|
||
self.active: dict[str, int] = {}
|
||
self.peak: dict[str, int] = {}
|
||
self.global_active = 0
|
||
self.global_peak = 0
|
||
|
||
async def __call__(
|
||
self,
|
||
provider: str,
|
||
url: str,
|
||
*,
|
||
origin: str | None = None,
|
||
cookies: dict | None = None,
|
||
reuse_context: bool = False,
|
||
reset_context: bool = False,
|
||
) -> str:
|
||
self.active[provider] = self.active.get(provider, 0) + 1
|
||
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])
|
||
self.global_active += 1
|
||
self.global_peak = max(self.global_peak, self.global_active)
|
||
try:
|
||
await asyncio.sleep(self.hold)
|
||
finally:
|
||
self.active[provider] -= 1
|
||
self.global_active -= 1
|
||
return f"<html>{provider}</html>"
|
||
|
||
|
||
def test_same_provider_fetches_are_serialized(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Два /fetch к ОДНОМУ поставщику сериализованы (один лок) — peak==1."""
|
||
probe = _OverlapProbe()
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
server._browsers[provider] = object()
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
monkeypatch.setattr(server, "_do_fetch", probe)
|
||
|
||
async def _run() -> None:
|
||
await asyncio.gather(
|
||
server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
|
||
server.fetch_handler(_make_fetch_request("https://www.avito.ru/b")),
|
||
server.fetch_handler(_make_fetch_request("https://www.avito.ru/c")),
|
||
)
|
||
|
||
asyncio.run(_run())
|
||
assert probe.peak["avito"] == 1, "внутри поставщика строго ≤1 одновременный fetch"
|
||
|
||
|
||
def test_different_providers_fetch_concurrently(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Два /fetch к РАЗНЫМ поставщикам идут параллельно (разные локи) — global peak>1."""
|
||
probe = _OverlapProbe()
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
server._browsers[provider] = object()
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
monkeypatch.setattr(server, "_do_fetch", probe)
|
||
|
||
async def _run() -> list[Any]:
|
||
return await asyncio.gather(
|
||
server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
|
||
server.fetch_handler(_make_fetch_request("https://www.cian.ru/b")),
|
||
server.fetch_handler(_make_fetch_request("https://realty.yandex.ru/c")),
|
||
)
|
||
|
||
responses = asyncio.run(_run())
|
||
assert all(r.status == 200 for r in responses)
|
||
# >=2 (не ==3): на нагруженном CI планировщик может не вывести все три корутины
|
||
# в overlap одновременно; перекрытие хотя бы двух уже доказывает параллельность
|
||
# между поставщиками. Same-provider тест (peak==1) остаётся строгим.
|
||
assert probe.global_peak >= 2, "разные поставщики должны фетчить конкурентно"
|
||
# У каждого поставщика по одному запросу — внутренний peak == 1.
|
||
assert probe.peak == {"avito": 1, "cian": 1, "yandex": 1}
|
||
|
||
|
||
def test_mixed_load_serializes_within_parallelizes_across(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Смешанная нагрузка: avito×2 сериализованы, но параллельны с cian×1."""
|
||
probe = _OverlapProbe()
|
||
|
||
async def _ensure(provider: str, proxy_override: str | None = None) -> bool:
|
||
server._browsers[provider] = object()
|
||
return True
|
||
|
||
monkeypatch.setattr(server, "_ensure_browser", _ensure)
|
||
monkeypatch.setattr(server, "_do_fetch", probe)
|
||
|
||
async def _run() -> None:
|
||
await asyncio.gather(
|
||
server.fetch_handler(_make_fetch_request("https://www.avito.ru/a")),
|
||
server.fetch_handler(_make_fetch_request("https://www.avito.ru/b")),
|
||
server.fetch_handler(_make_fetch_request("https://www.cian.ru/c")),
|
||
)
|
||
|
||
asyncio.run(_run())
|
||
assert probe.peak["avito"] == 1, "avito сериализован сам с собой"
|
||
assert probe.peak["cian"] == 1
|
||
assert probe.global_peak >= 2, "avito и cian должны перекрываться во времени"
|
||
|
||
|
||
# ── _parse_block_types: env-парсинг ───────────────────────────────────────────────
|
||
|
||
|
||
def test_parse_block_types_default() -> None:
|
||
"""None (env не задана) → дефолт font,media."""
|
||
assert server._parse_block_types(None) == frozenset({"font", "media"})
|
||
|
||
|
||
def test_parse_block_types_empty_disables() -> None:
|
||
"""Пустая строка → пустой набор (ничего не блокируем)."""
|
||
assert server._parse_block_types("") == frozenset()
|
||
assert server._parse_block_types(" ") == frozenset()
|
||
assert server._parse_block_types(" , , ") == frozenset()
|
||
|
||
|
||
def test_parse_block_types_custom_csv() -> None:
|
||
"""CSV парсится с trim/lower, дубликаты схлопываются."""
|
||
assert server._parse_block_types("font, Media , font") == frozenset({"font", "media"})
|
||
assert server._parse_block_types("FONT,IMAGE") == frozenset({"font", "image"})
|
||
|
||
|
||
# ── route-handler: блокировка font/media, пропуск document/script/css/xhr ──────────
|
||
|
||
|
||
class _FakeRequest:
|
||
def __init__(self, resource_type: str) -> None:
|
||
self.resource_type = resource_type
|
||
|
||
|
||
class _FakeRoute:
|
||
"""Мок Playwright Route: фиксирует, был ли abort или continue_."""
|
||
|
||
def __init__(self, resource_type: str) -> None:
|
||
self.request = _FakeRequest(resource_type)
|
||
self.aborted = False
|
||
self.continued = False
|
||
|
||
async def abort(self) -> None:
|
||
self.aborted = True
|
||
|
||
async def continue_(self) -> None:
|
||
self.continued = True
|
||
|
||
|
||
class _CapturePage:
|
||
"""Мок page: ловит handler, переданный в page.route, чтобы дёрнуть его напрямую."""
|
||
|
||
def __init__(self) -> None:
|
||
self.handler: Any = None
|
||
self.route_pattern: Any = None
|
||
|
||
async def route(self, pattern: str, handler: Any) -> None:
|
||
self.route_pattern = pattern
|
||
self.handler = handler
|
||
|
||
|
||
def test_resource_block_aborts_font_and_media(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Дефолтный набор font/media → abort; document/script/stylesheet/xhr/fetch → continue."""
|
||
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font", "media"}))
|
||
page = _CapturePage()
|
||
|
||
asyncio.run(server._apply_resource_block(page))
|
||
assert page.handler is not None, "route-handler должен быть навешен"
|
||
assert page.route_pattern == "**/*"
|
||
|
||
async def _check(resource_type: str) -> _FakeRoute:
|
||
route = _FakeRoute(resource_type)
|
||
await page.handler(route)
|
||
return route
|
||
|
||
# Блокируемые типы → abort
|
||
for rt in ("font", "media"):
|
||
route = asyncio.run(_check(rt))
|
||
assert route.aborted is True and route.continued is False, f"{rt} должен abort'иться"
|
||
|
||
# Критичные для extraction/fingerprint типы → continue
|
||
for rt in ("document", "script", "stylesheet", "xhr", "fetch", "image"):
|
||
route = asyncio.run(_check(rt))
|
||
assert route.continued is True and route.aborted is False, f"{rt} должен проходить"
|
||
|
||
|
||
def test_resource_block_noop_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Пустой _BLOCKED_TYPES → page.route не навешивается (no-op)."""
|
||
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset())
|
||
page = _CapturePage()
|
||
asyncio.run(server._apply_resource_block(page))
|
||
assert page.handler is None, "при пустом наборе route навешивать не должны"
|
||
|
||
|
||
def test_resource_block_handler_degrades_to_continue(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Сбой abort() деградирует в continue_() — битый ресурс не рвёт навигацию."""
|
||
monkeypatch.setattr(server, "_BLOCKED_TYPES", frozenset({"font"}))
|
||
page = _CapturePage()
|
||
asyncio.run(server._apply_resource_block(page))
|
||
|
||
class _FlakyRoute(_FakeRoute):
|
||
async def abort(self) -> None:
|
||
raise RuntimeError("route already handled")
|
||
|
||
route = _FlakyRoute("font")
|
||
asyncio.run(page.handler(route))
|
||
assert route.continued is True, "при сбое abort должны fallback'нуть в continue_()"
|
||
|
||
|
||
# ── _pace_provider: межстраничный пейсинг одного провайдера ────────────────────────
|
||
|
||
|
||
def test_pace_provider_first_call_no_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Первый goto провайдера — без ожидания, но timestamp пишется."""
|
||
# Интервал задаём в _MIN_PAGE_INTERVAL_BY_PROVIDER — ИМЕННО его читает
|
||
# _pace_provider (per-provider override, #1812 follow-up). Модульный
|
||
# BROWSER_MIN_PAGE_INTERVAL_S там только фолбэк для провайдера вне словаря,
|
||
# так что патч глобала на известном провайдере не влияет ни на что.
|
||
monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0)
|
||
slept: list[float] = []
|
||
|
||
async def _fake_sleep(d: float) -> None:
|
||
slept.append(d)
|
||
|
||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||
asyncio.run(server._pace_provider("avito"))
|
||
assert slept == [], "первый вызов не должен спать"
|
||
assert "avito" in server._last_goto_at
|
||
|
||
|
||
def test_pace_provider_waits_remaining_interval(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Второй goto в пределах интервала → sleep на остаток (interval - elapsed)."""
|
||
monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0)
|
||
slept: list[float] = []
|
||
|
||
async def _fake_sleep(d: float) -> None:
|
||
slept.append(d)
|
||
|
||
# _pace_provider читает time() дважды за вызов (now + запись last).
|
||
# 1-й вызов: now=100.0, last←100.0. 2-й: now=100.5 (elapsed=0.5→remaining=1.5),
|
||
# после sleep last←102.0.
|
||
clock = iter([100.0, 100.0, 100.5, 102.0])
|
||
|
||
class _FakeLoop:
|
||
def time(self) -> float:
|
||
return next(clock)
|
||
|
||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
|
||
|
||
async def _run() -> None:
|
||
await server._pace_provider("cian") # первый — last=100.0
|
||
await server._pace_provider("cian") # второй — elapsed=0.5, remaining=1.5
|
||
|
||
asyncio.run(_run())
|
||
assert len(slept) == 1, "второй вызов должен один раз sleep'нуть"
|
||
assert slept[0] == pytest.approx(1.5), "ждём остаток интервала (2.0 - 0.5)"
|
||
|
||
|
||
def test_pace_provider_no_sleep_when_interval_elapsed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Если с прошлого goto прошло >= интервала → не спим."""
|
||
monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "yandex", 2.0)
|
||
slept: list[float] = []
|
||
|
||
async def _fake_sleep(d: float) -> None:
|
||
slept.append(d)
|
||
|
||
# 2 чтения time() на вызов: [now1, last1, now2, last2]; elapsed=5.0 > 2.0 → no sleep.
|
||
clock = iter([100.0, 100.0, 105.0, 105.0])
|
||
|
||
class _FakeLoop:
|
||
def time(self) -> float:
|
||
return next(clock)
|
||
|
||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
|
||
|
||
async def _run() -> None:
|
||
await server._pace_provider("yandex")
|
||
await server._pace_provider("yandex") # elapsed=5.0 > 2.0 → no sleep
|
||
|
||
asyncio.run(_run())
|
||
assert slept == [], "интервал уже прошёл — спать не нужно"
|
||
|
||
|
||
def test_pace_provider_disabled_when_zero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Эффективный интервал провайдера <= 0 → пейсинг выключен, timestamp не пишется."""
|
||
monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 0.0)
|
||
slept: list[float] = []
|
||
|
||
async def _fake_sleep(d: float) -> None:
|
||
slept.append(d)
|
||
|
||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||
|
||
async def _run() -> None:
|
||
await server._pace_provider("avito")
|
||
await server._pace_provider("avito")
|
||
|
||
asyncio.run(_run())
|
||
assert slept == []
|
||
assert "avito" not in server._last_goto_at
|
||
|
||
|
||
def test_pace_provider_independent_per_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Timestamp независим по провайдерам — пейсинг avito не влияет на cian."""
|
||
monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "avito", 2.0)
|
||
monkeypatch.setitem(server._MIN_PAGE_INTERVAL_BY_PROVIDER, "cian", 2.0)
|
||
slept: list[float] = []
|
||
|
||
async def _fake_sleep(d: float) -> None:
|
||
slept.append(d)
|
||
|
||
# 2 чтения time() на вызов: avito [100.0, 100.0]; cian первый [100.1, 100.1]
|
||
# (нет last для cian → no sleep).
|
||
clock = iter([100.0, 100.0, 100.1, 100.1])
|
||
|
||
class _FakeLoop:
|
||
def time(self) -> float:
|
||
return next(clock)
|
||
|
||
monkeypatch.setattr(server.asyncio, "sleep", _fake_sleep)
|
||
monkeypatch.setattr(server.asyncio, "get_event_loop", lambda: _FakeLoop())
|
||
|
||
async def _run() -> None:
|
||
await server._pace_provider("avito")
|
||
await server._pace_provider("cian")
|
||
|
||
asyncio.run(_run())
|
||
assert slept == [], "разные провайдеры не пейсят друг друга"
|
||
|
||
|
||
# ── #3185: _resolve_block_images (per-provider block_images) ───────────────────────
|
||
|
||
|
||
def test_resolve_block_images_default_avito_unblocked() -> None:
|
||
"""Код-дефолт: avito НЕ блокирует картинки (QRATOR-детект, ~32% заглушек)."""
|
||
assert server._resolve_block_images("avito", environ={}) is False
|
||
|
||
|
||
def test_resolve_block_images_default_others_blocked() -> None:
|
||
"""Код-дефолт: cian/yandex/generic блокируют как раньше (не измерены)."""
|
||
assert server._resolve_block_images("cian", environ={}) is True
|
||
assert server._resolve_block_images("yandex", environ={}) is True
|
||
assert server._resolve_block_images("generic", environ={}) is True
|
||
|
||
|
||
def test_resolve_block_images_global_env_overrides_default_both_ways() -> None:
|
||
"""Глобальный BROWSER_BLOCK_IMAGES перебивает код-дефолт для ЛЮБОГО провайдера."""
|
||
assert server._resolve_block_images("avito", environ={"BROWSER_BLOCK_IMAGES": "true"}) is True
|
||
assert server._resolve_block_images("cian", environ={"BROWSER_BLOCK_IMAGES": "false"}) is False
|
||
|
||
|
||
def test_resolve_block_images_per_provider_env_overrides_global() -> None:
|
||
"""Per-provider BROWSER_BLOCK_IMAGES_{PROVIDER} сильнее и global env, и дефолта."""
|
||
env = {"BROWSER_BLOCK_IMAGES": "false", "BROWSER_BLOCK_IMAGES_CIAN": "true"}
|
||
assert server._resolve_block_images("cian", environ=env) is True
|
||
# avito не упомянут per-provider'ом → берёт global (false).
|
||
assert server._resolve_block_images("avito", environ=env) is False
|
||
|
||
|
||
def test_resolve_block_images_per_provider_env_overrides_avito_default() -> None:
|
||
"""BROWSER_BLOCK_IMAGES_AVITO=true может включить блокировку и для avito."""
|
||
env = {"BROWSER_BLOCK_IMAGES_AVITO": "true"}
|
||
assert server._resolve_block_images("avito", environ=env) is True
|
||
|
||
|
||
def test_resolve_block_images_unrecognized_value_is_falsy() -> None:
|
||
"""Нераспознанная строка per-provider env — как везде в _parse_bool: не True."""
|
||
env = {"BROWSER_BLOCK_IMAGES_CIAN": "not-a-bool"}
|
||
assert server._resolve_block_images("cian", environ=env) is False
|
||
|
||
|
||
# ── #2674: отставные переменные окружения ────────────────────────────────────
|
||
# BROWSER_BLOCK_RESOURCES стоит =true во всех трёх прод-контейнерах, а код перестал
|
||
# её читать в #1812 (булев выключатель → BROWSER_BLOCK_RESOURCE_TYPES). Блокировка при
|
||
# этом не ослабла (image глушит camoufox block_images, font/media — дефолт списка), но
|
||
# сама ручка мертва: выставив =false, оператор ничего не выключит и сделает вывод о
|
||
# блокировке, а не о переменной. Сервис обязан сказать это вслух на старте.
|
||
|
||
|
||
def test_retired_env_warns_when_set(
|
||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false")
|
||
with caplog.at_level("WARNING"):
|
||
server._warn_retired_env()
|
||
assert any("BROWSER_BLOCK_RESOURCES" in r.getMessage() for r in caplog.records), (
|
||
"мёртвая переменная выставлена, а сервис молчит"
|
||
)
|
||
|
||
|
||
def test_retired_env_silent_when_unset(
|
||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
monkeypatch.delenv("BROWSER_BLOCK_RESOURCES", raising=False)
|
||
with caplog.at_level("WARNING"):
|
||
server._warn_retired_env()
|
||
assert caplog.records == [], "предупреждение без повода — это шум, а не сигнал"
|
||
|
||
|
||
def test_retired_env_does_not_change_blocking(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Отставная переменная остаётся отставной: блокировкой правит только список типов."""
|
||
monkeypatch.setenv("BROWSER_BLOCK_RESOURCES", "false")
|
||
assert server._parse_block_types(None) == frozenset({"font", "media"})
|
||
|
||
|
||
async def _coro(value: Any) -> Any:
|
||
return value
|