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