gendesign/tradein-mvp/backend/tests/test_house_imv_backfill_browser_flag.py
Light1YT 0d010a707c
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein-imv): route IMV backfill через BrowserFetcher.fetch_json за dormant-флагом (#915 Stage 3)
Транспорт-своп для 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>
2026-06-26 14:42:26 +05:00

98 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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