gendesign/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py
bot-backend 0b9dfcc691
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 52s
feat(tradein/domclick): cookie-injection MVP для обхода QRATOR-блока (#2000)
Эмпирически подтверждено вживую 2026-07-04: инъекция cookies валидной
аутентифицированной test-аккаунт сессии (Sber ID) полностью обходит QRATOR
reputation-блок DomClick, даже на уже подозрительном прокси-IP (проверено:
проход через camoufox context.addCookies() → 200 + полный __SSR_STATE__ на
IP, который секундами ранее отдавал anti-bot страницу без cookies).

- domclick_session_cookies (миграция 174) + app/services/domclick_session.py —
  зеркалит существующий cian_session.py (pgp_sym_encrypt, account_cas_id вместо
  account_user_id), без verify_session (DomClick-верификация требует реального
  browser-фетча, не curl_cffi — future enhancement).
- POST /scrape/domclick/upload-cookies — ручная загрузка (зеркалит upload_cian_cookies).
- POST /scrape/domclick/debug/detail-fetch — единственный сейчас живой путь
  прогнать связку session→cookies→fetch_detail (боевой backfill-оркестратор
  для DomClick ещё не смёржен). source="domclick" — выделенный резидентный
  прокси-пул (id=1, provider_affinity='domclick'), построенный и verified
  в этой же сессии, а не устаревший source="cian" из старой рекомендации.
- _assert_domclick_host — SSRF-guard для *.domclick.ru поддоменов (реальные
  карточки живут на региональных поддоменах, не на голом domclick.ru).
- server.py/browser_fetcher.py: cookies-параметр расширяет origin-механизм
  (#2430) той же схемой — keyword-only, default None → byte-identical для
  avito/cian/yandex. Домен cookie выводится из hostname целевого URL
  (провайдер-агностично), не захардкожен под domclick.
2026-07-04 20:46:20 +03:00

234 lines
9.3 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.

"""Тесты browser-пула для kit `scraper_kit.browser_fetcher.BrowserFetcher` (#2164 P4).
Инвариант ship-dark + fallback:
- use_pool=False (дефолт) → в теле POST /fetch НЕТ поля "proxy"; proxy_provider не
трогается (golden-parity: браузер юзает свой env-прокси BROWSER_PROXY_*).
- use_pool=True + пул выдал lease → тело содержит "proxy"=lease.url + "proxy_kind";
на выходе mark_health(ok) + release (в finally — lease не течёт).
- use_pool=True + пул пуст (acquire→None) → тела без "proxy", НЕ падаем.
- use_pool=True + acquire бросил → fallback без "proxy", НЕ падаем.
- fetch кинул → mark_health(ok=False) + release всё равно (finally).
httpx полностью замокан: fetcher._client подменяется MagicMock'ом.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.contracts import ProxyLease
def _mock_client(json_payload: dict[str, Any], *, raise_exc: Exception | None = None) -> MagicMock:
"""httpx.AsyncClient-заглушка: .post → resp c raise_for_status/json."""
resp = MagicMock()
if raise_exc is not None:
resp.raise_for_status.side_effect = raise_exc
else:
resp.raise_for_status.return_value = None
resp.json.return_value = json_payload
client = MagicMock()
client.post = AsyncMock(return_value=resp)
return client
class _FakeProxyProvider:
"""ProxyProvider-заглушка: acquire отдаёт заданный lease (или None), считает вызовы."""
def __init__(self, lease: ProxyLease | None, *, acquire_raises: bool = False) -> None:
self._lease = lease
self._acquire_raises = acquire_raises
self.acquired: list[str] = []
self.released: list[int] = []
self.health: list[tuple[int, bool]] = []
def acquire(self, provider: str) -> ProxyLease | None:
self.acquired.append(provider)
if self._acquire_raises:
raise RuntimeError("pool boom")
return self._lease
def release(self, lease: ProxyLease) -> None:
self.released.append(lease.id)
def mark_health(self, lease: ProxyLease, ok: bool, **_: Any) -> None:
self.health.append((lease.id, ok))
def _fetcher(client: MagicMock, **kwargs: Any) -> BrowserFetcher:
bf = BrowserFetcher(endpoint="http://browser:3000", **kwargs)
bf._client = client
return bf
async def test_fetch_pool_off_no_proxy_in_body() -> None:
"""use_pool=False (дефолт) → тело без 'proxy', provider не трогается (parity)."""
provider = _FakeProxyProvider(ProxyLease(id=1, url="http://pool:8080", kind="http"))
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="avito", proxy_provider=provider) # use_pool default False
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
assert provider.acquired == [] # пул не трогали
async def test_fetch_pool_on_injects_proxy_and_releases() -> None:
"""use_pool=True + lease → 'proxy'/'proxy_kind' в теле; mark_health(True)+release."""
lease = ProxyLease(id=7, url="http://u:p@pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
await bf.fetch("https://avito.ru/x")
body = client.post.call_args.kwargs["json"]
assert body["proxy"] == "http://u:p@pool:8080"
assert body["proxy_kind"] == "http"
assert provider.acquired == ["avito"]
assert provider.health == [(7, True)]
assert provider.released == [7]
async def test_fetch_pool_empty_falls_back_no_proxy() -> None:
"""use_pool=True + пул пуст (acquire→None) → тело без 'proxy', без mark_health/release."""
provider = _FakeProxyProvider(None)
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="cian", proxy_provider=provider, use_pool=True)
await bf.fetch("https://cian.ru/x")
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
assert provider.acquired == ["cian"]
assert provider.released == []
assert provider.health == []
async def test_fetch_pool_acquire_error_falls_back() -> None:
"""acquire бросил → fallback без 'proxy', сбор НЕ падает."""
provider = _FakeProxyProvider(None, acquire_raises=True)
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="yandex", proxy_provider=provider, use_pool=True)
html = await bf.fetch("https://yandex.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
assert provider.released == []
async def test_fetch_error_marks_health_false_and_releases() -> None:
"""fetch кинул → mark_health(ok=False) + release всё равно (finally, lease не течёт)."""
lease = ProxyLease(id=3, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
# raise_for_status кидает НЕ-httpx ошибку → fetch() не ретраит, пробрасывает наверх.
client = _mock_client({"html": "x"}, raise_exc=ValueError("bad status"))
bf = _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
with pytest.raises(ValueError):
await bf.fetch("https://avito.ru/x")
assert provider.health == [(3, False)]
assert provider.released == [3]
async def test_fetch_json_pool_on_injects_proxy() -> None:
"""fetch_json тоже прокидывает proxy из пула в тело /fetch-json."""
lease = ProxyLease(id=9, url="http://pool:8080", kind="http")
provider = _FakeProxyProvider(lease)
client = _mock_client({"status": 200, "body": "{}"})
bf = _fetcher(client, source="avito", proxy_provider=provider, use_pool=True)
await bf.fetch_json(
"https://avito.ru/api", method="POST", body="{}", origin="https://avito.ru/"
)
body = client.post.call_args.kwargs["json"]
assert body["proxy"] == "http://pool:8080"
assert body["proxy_kind"] == "http"
assert provider.released == [9]
async def test_fetch_json_pool_off_no_proxy() -> None:
"""use_pool=False → fetch_json без 'proxy' в теле (parity)."""
client = _mock_client({"status": 200, "body": "{}"})
bf = _fetcher(client, source="avito")
await bf.fetch_json("https://avito.ru/api")
body = client.post.call_args.kwargs["json"]
assert "proxy" not in body
# ── fetch() origin passthrough (DomClick SERP-anchor, зеркалит fetch_json) ───────
async def test_fetch_without_origin_sends_none() -> None:
"""fetch(url) без origin → payload содержит "origin": None (parity avito/cian/yandex).
Ни один провайдер кроме domclick не передаёт origin — сервер (body.get("origin"))
трактует None как «origin-goto не выполнять», поведение не меняется.
"""
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert body["origin"] is None
async def test_fetch_with_origin_sends_it_in_payload() -> None:
"""fetch(url, origin=X) → payload несёт origin=X (domclick vtorichka-SERP anchor)."""
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="cian")
await bf.fetch(
"https://ekaterinburg.domclick.ru/card/sale__flat__1",
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
)
body = client.post.call_args.kwargs["json"]
assert body["origin"] == "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
# ── fetch() cookies passthrough (DomClick cookie-injection, #2000) ───────────────
async def test_fetch_without_cookies_sends_none() -> None:
"""fetch(url) без cookies → payload содержит "cookies": None (parity avito/cian/yandex).
Ни один провайдер кроме domclick debug-fetch не передаёт cookies — сервер
(body.get("cookies")) трактует None как «инъекции не делать», поведение не меняется.
"""
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="avito")
html = await bf.fetch("https://avito.ru/x")
assert html == "<ok>"
body = client.post.call_args.kwargs["json"]
assert body["cookies"] is None
async def test_fetch_with_cookies_sends_them_in_payload() -> None:
"""fetch(url, cookies=X) → payload несёт cookies=X (DomClick session cookie-injection)."""
client = _mock_client({"html": "<ok>"})
bf = _fetcher(client, source="cian")
session_cookies = {"CAS_ID": "12345", "qrator_jsid2": "abc"}
await bf.fetch(
"https://ekaterinburg.domclick.ru/card/sale__flat__1",
cookies=session_cookies,
)
body = client.post.call_args.kwargs["json"]
assert body["cookies"] == session_cookies