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
Карточки Циана доставались побочным эффектом 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.
235 lines
12 KiB
Python
235 lines
12 KiB
Python
"""#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
|