gendesign/tradein-mvp/backend/tests/test_3284_cian_detail_backfill_queue.py
bot-backend 5f9dc5d512
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
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 / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m2s
feat(tradein/cian): у Циана не было добора карточек — только побочный эффект задачи про историю (#3284)
Карточки Циана доставались побочным эффектом cian_history_backfill, а её
выборка ключуется по offer_price_history. Следствия на 30.08: карточка есть
у 4494 из 25222 объявлений (17.8% — последнее место при втором месте по
объёму), 19046 без истории при квоте 100/сутки (190 дней на остаток, тогда
как очередь растёт вдвадцатеро быстрее), и 1697 объявлений с историей и без
карточки, которые исторической выборке недостижимы в принципе.

Фетчер при этом исправен: прогоны 5154/5240/5328 дали 100/100, 99/100,
100/100. Чинить нечего — не выдана мощность.

Добавлен второй режим выборки (listings_pending="detail", по
detail_enriched_at, свежие первыми) и второе расписание поверх ТОГО ЖЕ тела:
машинерия работает, дублировать её новым модулем незачем. Историческая
выборка оставлена побайтово — по ней живёт суточный прогон.

batch_size=400 не на глаз: замеренный темп ~28с на объявление, порог
reap_zombies 6ч по heartbeat, бюджетного сторожа у задачи нет — 400×28с≈3.1ч
проходит, 800 как у Яндекса (≈6.2ч) убивало бы жнецом.

Расписание засеяно enabled=false, как domclick_detail_backfill в миграции
175: это третий круглосуточный добор на общий пул из четырёх узлов, влияние
на соседей надо посмотреть, а не предположить.

Тесты: 13 проверок, ключ выборки / порядок / неизменность прежнего режима /
проводка параметров через посредника / регистрация обоих source. Проверено
мутациями: снятие ORDER BY, молчаливый дефолт вместо ValueError и потеря
listings_pending в посреднике роняют по 2-3 теста каждая. Набор целиком —
5196 passed, 37 skipped.
2026-08-30 15:34:36 +03:00

235 lines
12 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.

"""#3284: у Циана не было добора карточек — только побочный эффект задачи про историю цен.
Историческая выборка ключуется по offer_price_history. Объявление, у которого история
уже есть, а карточки нет, для неё «обработано» и не вернётся никогда — на 30.08.2026
таких 1697. Плюс квота 100/сутки против очереди в 19 046 — это 190 дней, при том что
Циан приносит ~4900 объявлений за двое суток.
Правка добавляет второй режим выборки (`listings_pending="detail"`) и второе расписание
поверх того же тела. Тесты ниже закрепляют ровно то, что делает режим полезным:
выборку по detail_enriched_at, свежие первыми, неизменность прежнего режима и отказ
на опечатке в default_params (иначе пустой батч читался бы как «всё добрано»).
"""
from __future__ import annotations
import os
import re
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.tasks import cian_history_backfill
from app.tasks.cian_history_backfill import _LISTINGS_PENDING_SQL
def _norm(sql: str) -> str:
"""Схлопнуть пробелы — сравниваем смысл запроса, а не его отступы."""
return re.sub(r"\s+", " ", sql).strip()
class _FakeBrowserFetcher:
def __init__(self, **kwargs: Any) -> None:
self.last_response_status = None
async def __aenter__(self) -> _FakeBrowserFetcher:
return self
async def __aexit__(self, *_: object) -> None:
return None
def _enrichment() -> SimpleNamespace:
return SimpleNamespace(price_changes=[])
def _db_returning(rows: list[dict[str, Any]]) -> MagicMock:
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
return db
async def _run(db: MagicMock, **kwargs: Any) -> Any:
with (
patch.object(cian_history_backfill, "BrowserFetcher", _FakeBrowserFetcher),
patch.object(cian_history_backfill, "fetch_detail", AsyncMock(return_value=_enrichment())),
patch.object(cian_history_backfill, "save_detail_enrichment", MagicMock()),
patch.object(cian_history_backfill, "RealMatcherAdapter", MagicMock()),
patch("asyncio.sleep", new_callable=AsyncMock),
):
return await cian_history_backfill.backfill_cian_history(
db, do_houses=False, do_valuations=False, **kwargs
)
# ── выборка "detail" ─────────────────────────────────────────────────────────
def test_detail_queue_selects_by_missing_card_not_by_history() -> None:
"""Ключ выборки — detail_enriched_at, а не offer_price_history.
Это вся суть #3284: пока признаком служит история, 1697 объявлений с историей
и без карточки недостижимы.
"""
sql = _norm(_LISTINGS_PENDING_SQL["detail"])
assert "l.detail_enriched_at IS NULL" in sql
assert "offer_price_history" not in sql
def test_detail_queue_takes_freshest_first() -> None:
"""Очередь длиннее батча, поэтому порядок решает, что мы успеем добрать."""
sql = _norm(_LISTINGS_PENDING_SQL["detail"])
assert "ORDER BY l.last_seen_at DESC NULLS LAST" in sql
assert sql.index("ORDER BY") < sql.index("LIMIT")
def test_detail_queue_stays_scoped_to_cian() -> None:
"""Источник обязан быть в WHERE: иначе добор Циана заберёт чужие объявления."""
assert "l.source = 'cian'" in _norm(_LISTINGS_PENDING_SQL["detail"])
assert "l.source_url IS NOT NULL" in _norm(_LISTINGS_PENDING_SQL["detail"])
# ── прежний режим не тронут ──────────────────────────────────────────────────
def test_history_queue_unchanged() -> None:
"""По этой выборке живёт суточный cian_history_backfill — она обязана остаться прежней."""
sql = _norm(_LISTINGS_PENDING_SQL["history"])
assert "LEFT JOIN offer_price_history oph ON oph.listing_id = l.id" in sql
assert "oph.listing_id IS NULL" in sql
assert "detail_enriched_at" not in sql
# Сортировки в исторической выборке не было и не появилось: её добавление —
# отдельное решение с отдельной проверкой, а не побочный эффект #3284.
assert "ORDER BY" not in sql
def test_default_mode_is_history() -> None:
"""Дефолт обязан оставаться прежним: расписание истории параметра не передаёт."""
db = _db_returning([])
import asyncio
asyncio.run(_run(db))
used = _norm(str(db.execute.call_args_list[0].args[0]))
assert "offer_price_history" in used
# ── режим доезжает до запроса ────────────────────────────────────────────────
async def test_detail_mode_reaches_the_query() -> None:
"""Параметр не должен потеряться по дороге — проверяем сам исполненный SQL."""
db = _db_returning([])
await _run(db, listings_pending="detail")
used = _norm(str(db.execute.call_args_list[0].args[0]))
assert "detail_enriched_at IS NULL" in used
assert "offer_price_history" not in used
async def test_detail_mode_processes_rows_normally() -> None:
"""Смена выборки не меняет обработку: строки те же, счётчики те же."""
db = _db_returning([{"id": 7, "source_url": "https://cian.ru/7"}])
result = await _run(db, listings_pending="detail", batch_size=10)
assert result.listings_processed == 1
assert result.listings_succeeded == 1
async def test_batch_size_reaches_the_query() -> None:
"""batch_size обязан доезжать как :lim — иначе квота из расписания ничего не значит."""
db = _db_returning([])
await _run(db, listings_pending="detail", batch_size=400)
assert db.execute.call_args_list[0].args[1] == {"lim": 400}
# ── опечатка не должна читаться как «всё добрано» ────────────────────────────
async def test_unknown_mode_raises_instead_of_silently_defaulting() -> None:
"""Опечатка в default_params обязана падать громко.
Молчаливый откат на "history" дал бы прогон с нулём добранных карточек, который
выглядит как штатный: очередь якобы пуста. Такую ошибку ищут днями.
"""
db = _db_returning([])
with pytest.raises(ValueError, match="listings_pending"):
await _run(db, listings_pending="detali")
async def test_unknown_mode_names_the_allowed_values() -> None:
"""Сообщение должно называть допустимые значения — иначе оно не помогает."""
db = _db_returning([])
with pytest.raises(ValueError) as e:
await _run(db, listings_pending="")
assert "detail" in str(e.value) and "history" in str(e.value)
# ── параметры доезжают от расписания до задачи ───────────────────────────────
# Три предыдущих теста проверяют саму задачу. Эти — путь от строки расписания:
# default_params → _execute_cian_backfill → backfill_cian_history. Без них правка
# в теле-посреднике молча вернула бы cian_detail_backfill к разбору истории.
async def test_scheduler_passes_detail_mode_from_params() -> None:
"""listings_pending из default_params обязан доехать до задачи."""
from app.services import scheduler as scheduler_mod
captured: dict[str, Any] = {}
async def _fake_backfill(db: Any, **kwargs: Any) -> Any:
captured.update(kwargs)
# Настоящий результат, а не SimpleNamespace: посредник читает у него
# поля, которых в самодельной заглушке легко недосчитаться.
return cian_history_backfill.CianBackfillResult()
with (
patch("app.tasks.cian_history_backfill.backfill_cian_history", _fake_backfill),
patch.object(scheduler_mod.runs_mod, "update_heartbeat", MagicMock()),
patch.object(scheduler_mod.runs_mod, "mark_done", MagicMock()),
):
await scheduler_mod._execute_cian_backfill(
MagicMock(),
run_id=1,
params={"batch_size": 400, "listings_pending": "detail", "do_houses": False},
)
assert captured["listings_pending"] == "detail"
assert captured["do_houses"] is False
assert captured["batch_size"] == 400
async def test_scheduler_defaults_stay_history_and_houses() -> None:
"""Без параметров поведение прежнее — по нему живёт суточный cian_history_backfill."""
from app.services import scheduler as scheduler_mod
captured: dict[str, Any] = {}
async def _fake_backfill(db: Any, **kwargs: Any) -> Any:
captured.update(kwargs)
# Настоящий результат, а не SimpleNamespace: посредник читает у него
# поля, которых в самодельной заглушке легко недосчитаться.
return cian_history_backfill.CianBackfillResult()
with (
patch("app.tasks.cian_history_backfill.backfill_cian_history", _fake_backfill),
patch.object(scheduler_mod.runs_mod, "update_heartbeat", MagicMock()),
patch.object(scheduler_mod.runs_mod, "mark_done", MagicMock()),
):
await scheduler_mod._execute_cian_backfill(MagicMock(), run_id=1, params={})
assert captured["listings_pending"] == "history"
assert captured["do_houses"] is True
def test_both_cian_sources_are_registered() -> None:
"""Оба source обязаны быть в реестре: планировщик держит на source один прогон,
поэтому два режима не могут делить одно имя."""
from app.services.product_handlers import build_product_handlers
handlers = build_product_handlers(MagicMock())
assert "cian_history_backfill" in handlers
assert "cian_detail_backfill" in handlers
# Гейт кук общий: он читает schedule_row["source"], а не хардкодит имя.
assert handlers["cian_detail_backfill"].pre_claim is handlers["cian_history_backfill"].pre_claim