Транспорт-своп для avito IMV backfill: вместо curl_cffi 4 IMV-запроса (warm-up → coords → position → get-data) ходят через камуфокс in-page fetch (/fetch-json sidecar). Same-origin avito.ru + прокси + warmed-cookies + реальный fingerprint обходят datacenter-403 (#562/#853) без отдельного IMV-прокси. За dormant-флагом avito_imv_use_browser_fetcher (default False) — деплой не меняет prod IMV-поведение. - avito_imv.py: _BrowserResponse + _BrowserSessionAdapter (мимикрия под curl_cffi session/response: .get/.post/.close, .status_code/.text/.json/ .raise_for_status). evaluate_via_imv получает browser_fetcher kwarg; при его наличии адаптер вместо curl-сессии (browser-путь не требует curl_cffi). - config.py: avito_imv_use_browser_fetcher (ENV AVITO_IMV_USE_BROWSER_FETCHER). - house_imv_backfill.py: backfill_house_imv при флаге открывает ОДИН BrowserFetcher(source="avito") на батч и прокидывает в _process_one_house → evaluate_via_imv. Флаг OFF → browser_fetcher=None → curl-путь как раньше. Backfill-only: estimator и sweep-путь (process_houses_imv_batch) не тронуты. - browser_fetcher.py: _post_fetch_json защищён от ответа без status/body keys. - test_server_fetch_json.py: тест crash-retry ветки _do_fetch_json (relaunch + второй _fetch_json_once после browser-crash). - tests: _BrowserResponse/_BrowserSessionAdapter юниты, evaluate_via_imv browser happy-path + 403→IMVAuthError, backfill flag on/off проводка. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
98 lines
3.8 KiB
Python
98 lines
3.8 KiB
Python
"""test_house_imv_backfill_browser_flag.py — #915 Stage 3.
|
||
|
||
Проверяет проводку флага avito_imv_use_browser_fetcher в backfill_house_imv:
|
||
- флаг ON → конструируется один BrowserFetcher(source="avito") на весь батч,
|
||
и он прокидывается в _process_one_house(browser_fetcher=...).
|
||
- флаг OFF → BrowserFetcher НЕ конструируется, browser_fetcher=None (старый путь).
|
||
|
||
DB и _process_one_house мокаются — реальный камуфокс/curl/Postgres не нужны.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any, ClassVar
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
# settings нужен DSN на импорте (как в соседних тестах) — fake-db, статический.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services import house_imv_backfill
|
||
|
||
|
||
class _FakeBrowserFetcher:
|
||
"""Поддельный async-CM: считает конструкции/входы, возвращает себя из __aenter__."""
|
||
|
||
instances: ClassVar[list[_FakeBrowserFetcher]] = []
|
||
|
||
def __init__(self, source: str = "avito", **_: Any) -> None:
|
||
self.source = source
|
||
self.entered = False
|
||
_FakeBrowserFetcher.instances.append(self)
|
||
|
||
async def __aenter__(self) -> _FakeBrowserFetcher:
|
||
self.entered = True
|
||
return self
|
||
|
||
async def __aexit__(self, *_: object) -> None:
|
||
return None
|
||
|
||
|
||
def _fake_db_with_one_row() -> MagicMock:
|
||
"""MagicMock-Session, чей execute(...).mappings().all() отдаёт один дом."""
|
||
db = MagicMock()
|
||
row = {"id": 42, "address": "ЕКБ, ул. X, 1", "full_address": None, "lat": 56.8, "lon": 60.6}
|
||
db.execute.return_value.mappings.return_value.all.return_value = [row]
|
||
return db
|
||
|
||
|
||
async def test_backfill_flag_on_constructs_browser_and_threads_it(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
_FakeBrowserFetcher.instances.clear()
|
||
monkeypatch.setattr(house_imv_backfill.settings, "avito_imv_use_browser_fetcher", True)
|
||
monkeypatch.setattr(house_imv_backfill, "BrowserFetcher", _FakeBrowserFetcher)
|
||
|
||
captured: dict[str, Any] = {}
|
||
|
||
async def _fake_process(db: Any, house: dict, *, browser_fetcher: Any = None) -> str:
|
||
captured["browser_fetcher"] = browser_fetcher
|
||
return "ok"
|
||
|
||
monkeypatch.setattr(house_imv_backfill, "_process_one_house", _fake_process)
|
||
|
||
db = _fake_db_with_one_row()
|
||
await house_imv_backfill.backfill_house_imv(db, batch_size=10)
|
||
|
||
# Ровно один BrowserFetcher(source="avito"), вошли в контекст.
|
||
assert len(_FakeBrowserFetcher.instances) == 1
|
||
bf = _FakeBrowserFetcher.instances[0]
|
||
assert bf.source == "avito"
|
||
assert bf.entered is True
|
||
# И он прокинут в _process_one_house.
|
||
assert captured["browser_fetcher"] is bf
|
||
|
||
|
||
async def test_backfill_flag_off_no_browser_and_none_threaded(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
_FakeBrowserFetcher.instances.clear()
|
||
monkeypatch.setattr(house_imv_backfill.settings, "avito_imv_use_browser_fetcher", False)
|
||
monkeypatch.setattr(house_imv_backfill, "BrowserFetcher", _FakeBrowserFetcher)
|
||
|
||
captured: dict[str, Any] = {}
|
||
|
||
async def _fake_process(db: Any, house: dict, *, browser_fetcher: Any = None) -> str:
|
||
captured["browser_fetcher"] = browser_fetcher
|
||
return "ok"
|
||
|
||
monkeypatch.setattr(house_imv_backfill, "_process_one_house", _fake_process)
|
||
|
||
db = _fake_db_with_one_row()
|
||
await house_imv_backfill.backfill_house_imv(db, batch_size=10)
|
||
|
||
# Флаг OFF → BrowserFetcher не конструируется, browser_fetcher=None.
|
||
assert _FakeBrowserFetcher.instances == []
|
||
assert captured["browser_fetcher"] is None
|