gendesign/tradein-mvp/backend/tests/test_3264_domclick_sweep_cookies.py
bot-backend 44633b0df4
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m53s
fix(tradein/domclick): свип ходил на QRATOR без кук и вис на PoW каждым запросом (#3264)
serp.py не передавал куки сессии вообще — слова cookie в файле не было.
Свип приходил к QRATOR с чистым браузером и был обязан решать
proof-of-work с нуля на каждый запрос. Прод, прогон 5330 (30.08
03:49-03:55): 9 запросов к bff-search-web.domclick.ru, все 9 зависли на
челлендже (перезагрузка 1/2, 2/2, отказ), status=failed, 0 лотов. Прокси
при этом ротировался (узел 9 → 10) — узел тут ни при чём.

Добор с тем же сайдкаром и тем же пулом в ту же ночь взял 37 карточек из
37 без единого блока. Разница ровно в куках.

Чинить это стало возможно только сейчас: свип ходит не на
ekaterinburg.domclick.ru, а на отдельный хост bff-search-web.domclick.ru,
и до правки _cookie_domain (PR #3262) куки легли бы на
.bff-search-web.domclick.ru, не совпав с сессией площадки. Теперь оба
хоста схлопываются в общий .domclick.ru.

Снимок приходит параметром снаружи, а не читается внутри kit: kit не
импортирует app.* (strangler-инвариант #2133). Поэтому джоба
domclick_city_sweep переопределена продуктовым Handler'ом — build_registry
это прямо допускает («последнее слово за продуктом»), а БД читает только
app-сторона.

Отсутствие сессии не авария: load_session вернул None → свип идёт как
раньше, без инъекции, факт логируется один раз.

Цена решения — override повторяет вызов kit-джобы целиком и может тихо с
ней разойтись. Добавлен тест, который зовёт оба джоба одинаково и
сравнивает наборы kwargs, допуская расхождение ровно в cookies. Проверен
мутацией: с искусственно добавленным в kit-версию аргументом краснеет,
без него зелёный.

Тесты: 245 passed, 1 skipped (domclick + parity).
2026-08-30 09:40:21 +03:00

297 lines
13 KiB
Python
Raw Permalink 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.

"""#3264: свип DomClick (`domclick_city_sweep`) ходил на bff-search-web.domclick.ru БЕЗ
кук сессии и был обязан решать QRATOR PoW с нуля на каждый запрос — прод 30.08 (прогон
5330): 9/9 запросов зависли на challenge, 0 листингов. `domclick_detail_backfill` (добор
карточек) уже решал эту задачу инъекцией снимка `domclick_session.load_session` — этот
файл проверяет, что тот же снимок теперь доезжает и до свипа:
1. DomClickScraper(cookies=...) кладёт снимок в self._cookies и пробрасывает его в
ОБА fetcher.fetch() (count-probe и paginate-leaf).
2. run_domclick_city_sweep(cookies=...) пробрасывает их в DomClickScraper(...).
3. product_handlers._job_domclick_city_sweep (app-side override kit-native, см. его
докстринг) грузит куки через domclick_session.load_session(db) и не падает, когда
сессии в БД нет/протухла (load_session вернул None) — свип в этом случае работает
как раньше, без инъекции.
Не проверяется (вне периметра): реальный обход QRATOR, browser_fetcher.fetch()
внутренности (уже покрыты test_domclick_detail.py), ротация прокси/корзин (#2854/#2670).
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from scraper_kit.providers.domclick.serp import DomClickScraper
PFX = "scraper_kit.orchestration.pipeline"
def _config() -> SimpleNamespace:
return SimpleNamespace(
scraper_fetch_mode="curl_cffi",
browser_http_endpoint="http://browser.test/fetch",
scraper_proxy_url=None,
avito_proxy_max_rotations=0,
avito_serp_ok_not_banned=True,
avito_proxy_rotate_settle_s=0.0,
proxy_rotate_attempts=1,
proxy_rotate_attempt_timeout_s=1.0,
cian_proxy_max_rotations=0,
yandex_proxy_max_rotations=0,
scraper_skip_seen_today=False,
cian_full_load_per_fetch_timeout_s=0.0,
)
# ── 1. DomClickScraper: cookies → self._cookies → оба fetcher.fetch() ────────────────
def test_domclick_scraper_stores_cookies() -> None:
snapshot = {"sbjs": "x", "dvc": "y"}
scraper = DomClickScraper(_config(), cookies=snapshot)
assert scraper._cookies == snapshot
def test_domclick_scraper_defaults_cookies_to_none() -> None:
scraper = DomClickScraper(_config())
assert scraper._cookies is None
@pytest.mark.asyncio
async def test_count_passes_cookies_to_fetch() -> None:
snapshot = {"sbjs": "x"}
scraper = DomClickScraper(_config(), cookies=snapshot)
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value='{"result": {"snippetsCount": 3}}')
n = await scraper._count(fetcher, "st", None, None)
assert n == 3
assert fetcher.fetch.await_args.kwargs["cookies"] == snapshot
@pytest.mark.asyncio
async def test_count_passes_none_cookies_without_error() -> None:
"""Cookies отсутствуют/протухли — не авария, fetch() зовётся с cookies=None."""
scraper = DomClickScraper(_config(), cookies=None)
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value='{"result": {"snippetsCount": 0}}')
await scraper._count(fetcher, "st", None, None)
assert fetcher.fetch.await_args.kwargs["cookies"] is None
@pytest.mark.asyncio
async def test_paginate_passes_cookies_to_fetch() -> None:
snapshot = {"sbjs": "x"}
scraper = DomClickScraper(_config(), cookies=snapshot)
fetcher = MagicMock()
fetcher.fetch = AsyncMock(return_value='{"result": {"items": []}}')
out: list[object] = []
await scraper._paginate(fetcher, "st", None, None, set(), out, pages=1)
assert fetcher.fetch.await_args.kwargs["cookies"] == snapshot
# ── 2. run_domclick_city_sweep: cookies → DomClickScraper(...) ───────────────────────
def _stub_runs() -> MagicMock:
"""runs-двойник: is_cancelled=False (иначе sweep выходит ДО создания scraper'а)."""
m = MagicMock()
m.is_cancelled = MagicMock(return_value=False)
return m
@pytest.mark.asyncio
async def test_run_domclick_city_sweep_passes_cookies_to_scraper() -> None:
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
snapshot = {"sbjs": "x"}
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_city = AsyncMock(return_value=[])
scraper.blocked = False
scraper.geo_filtered = 0
scraper.fetch_errors = 0
scraper.buckets_completed = len(ROOM_BUCKETS)
scraper.buckets_total = len(ROOM_BUCKETS)
ctor = MagicMock(return_value=scraper)
db = MagicMock()
with (
patch(f"{PFX}.DomClickScraper", ctor),
patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))),
patch(f"{PFX}.runs", _stub_runs()),
):
await run_domclick_city_sweep(
db,
config=_config(),
matcher=MagicMock(),
run_id=1,
city_id=4,
pages=1,
request_delay_sec=0.0,
cookies=snapshot,
)
assert ctor.call_args.kwargs["cookies"] == snapshot
@pytest.mark.asyncio
async def test_run_domclick_city_sweep_none_cookies_does_not_break() -> None:
"""cookies=None (дефолт) — прежнее поведение, свип не падает."""
from scraper_kit.orchestration.pipeline import run_domclick_city_sweep
from scraper_kit.providers.domclick.serp import ROOM_BUCKETS
scraper = MagicMock()
scraper.__aenter__ = AsyncMock(return_value=scraper)
scraper.__aexit__ = AsyncMock(return_value=None)
scraper.fetch_city = AsyncMock(return_value=[])
scraper.blocked = False
scraper.geo_filtered = 0
scraper.fetch_errors = 0
scraper.buckets_completed = len(ROOM_BUCKETS)
scraper.buckets_total = len(ROOM_BUCKETS)
ctor = MagicMock(return_value=scraper)
db = MagicMock()
with (
patch(f"{PFX}.DomClickScraper", ctor),
patch(f"{PFX}.save_listings", MagicMock(return_value=(0, 0))),
patch(f"{PFX}.runs", _stub_runs()),
):
counters = await run_domclick_city_sweep(
db,
config=_config(),
matcher=MagicMock(),
run_id=1,
city_id=4,
pages=1,
request_delay_sec=0.0,
)
assert ctor.call_args.kwargs["cookies"] is None
assert counters.errors_count == 0
# ── 3. product_handlers._job_domclick_city_sweep: БД → cookies → sweep ───────────────
@pytest.mark.asyncio
async def test_job_domclick_city_sweep_loads_and_passes_cookies() -> None:
from app.services.product_handlers import _job_domclick_city_sweep
snapshot = {"sbjs": "x"}
db = MagicMock()
ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False)
with (
patch(
"app.services.domclick_session.load_session",
MagicMock(return_value=snapshot),
),
patch(
"scraper_kit.orchestration.pipeline.run_domclick_city_sweep",
AsyncMock(),
) as run_mock,
patch("app.services.product_handlers.kit_pick_resume", MagicMock(return_value=None)),
):
await _job_domclick_city_sweep(db, 1, {}, ctx)
assert run_mock.await_args.kwargs["cookies"] == snapshot
@pytest.mark.asyncio
async def test_job_domclick_city_sweep_none_session_is_not_fatal() -> None:
"""load_session вернул None (нет/протухла) — свип идёт БЕЗ инъекции, не падает."""
from app.services.product_handlers import _job_domclick_city_sweep
db = MagicMock()
ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False)
with (
patch("app.services.domclick_session.load_session", MagicMock(return_value=None)),
patch(
"scraper_kit.orchestration.pipeline.run_domclick_city_sweep",
AsyncMock(),
) as run_mock,
patch("app.services.product_handlers.kit_pick_resume", MagicMock(return_value=None)),
):
await _job_domclick_city_sweep(db, 1, {}, ctx)
assert run_mock.await_args.kwargs["cookies"] is None
def test_domclick_city_sweep_registered_as_product_override() -> None:
"""build_product_handlers() переопределяет kit-native domclick_city_sweep (#3264)."""
from app.services.product_handlers import _job_domclick_city_sweep, build_product_handlers
handlers = build_product_handlers(ctx=None) # type: ignore[arg-type]
assert handlers["domclick_city_sweep"].job is _job_domclick_city_sweep
# ── 4. Дрейф override'а от kit-native (#3264) ────────────────────────────────────────
@pytest.mark.asyncio
async def test_product_override_matches_kit_native_call_except_cookies() -> None:
"""Продуктовый override зовёт sweep ТЕМИ ЖЕ аргументами, что kit-native, плюс cookies.
Override (app.services.product_handlers) не оборачивает kit-джобу, а повторяет её
вызов целиком — иначе пришлось бы тащить чтение БД внутрь kit, что запрещено
strangler-инвариантом #2133. Цена такого решения — дрейф: параметр, добавленный в
kit-native `_job_domclick_city_sweep`, в override не появится, и свип по расписанию
молча поедет со старым набором. Этот тест — единственное, что такой дрейф ловит:
оба джоба зовутся с одинаковыми db/run_id/params/ctx, и наборы kwargs сравниваются.
Расхождение допускается ровно одно — `cookies`, ради которого override и заведён.
"""
from scraper_kit.orchestration.scheduler import (
_job_domclick_city_sweep as kit_job,
)
from app.services.product_handlers import _job_domclick_city_sweep as product_job
params = {"city_id": 4, "pages_per_anchor": 7, "request_delay_sec": 3.0}
# Патчить sweep надо В ПРОСТРАНСTВЕ ИМЁН ВЫЗЫВАЮЩЕГО: kit-джоба связала имя
# `run_domclick_city_sweep` на импорте модуля, продуктовая импортирует его внутри
# тела. Патч по одному только `pipeline` kit-джобу не перехватит — она позовёт
# настоящий свип и уйдёт в сеть.
# db и ctx общие на оба захвата: иначе сравнение спотыкается не о дрейф, а о то,
# что ctx.config — разные MagicMock-объекты в двух вызовах.
db = MagicMock()
ctx = MagicMock(proxy_provider=None, shutdown_requested=lambda: False)
async def _capture(job, patch_sweep_in: str, patch_resume_in: str) -> dict:
with (
patch("app.services.domclick_session.load_session", MagicMock(return_value=None)),
patch(patch_sweep_in, AsyncMock()) as run_mock,
patch(patch_resume_in, MagicMock(return_value=None)),
):
await job(db, 1, params, ctx)
return run_mock.await_args.kwargs
kit_kwargs = await _capture(
kit_job,
"scraper_kit.orchestration.scheduler.run_domclick_city_sweep",
"scraper_kit.orchestration.scheduler._pick_resume",
)
product_kwargs = await _capture(
product_job,
f"{PFX}.run_domclick_city_sweep",
"app.services.product_handlers.kit_pick_resume",
)
assert set(product_kwargs) - set(kit_kwargs) == {"cookies"}, (
"override приобрёл аргументы сверх kit-native помимо cookies"
)
assert not set(kit_kwargs) - set(product_kwargs), (
"kit-native получил аргумент, которого нет в override — свип по расписанию "
"поедет без него (дрейф #3264)"
)
for key, kit_value in kit_kwargs.items():
assert product_kwargs[key] == kit_value, f"override разошёлся с kit-native по {key!r}"