From 476fb9b77a0148a80fd4de54de6d062610d31e81 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 8 Sep 2026 22:53:20 +0300 Subject: [PATCH] =?UTF-8?q?chore(#3051):=20=D1=81=D0=BD=D1=8F=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=E2=80=94=20=D0=B4=D0=BE=20=D0=B6=D0=B8=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B8=D1=88=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В существующем test_rosreestr_dedup_key оставлена только правка, без которой он падает после параметризации (литералы region_code=66 / doc_type='ДКП' ушли из SQL живого импорта). Новый тест-файл и добавленная функция сняты: поведение ещё не проверено на живом импорте, тест зафиксировал бы догадку. --- .../test_3051_deals_doc_type_region_param.py | 148 ------------------ .../backend/tests/test_rosreestr_dedup_key.py | 20 +-- 2 files changed, 1 insertion(+), 167 deletions(-) delete mode 100644 tradein-mvp/backend/tests/test_3051_deals_doc_type_region_param.py diff --git a/tradein-mvp/backend/tests/test_3051_deals_doc_type_region_param.py b/tradein-mvp/backend/tests/test_3051_deals_doc_type_region_param.py deleted file mode 100644 index f24ebaae..00000000 --- a/tradein-mvp/backend/tests/test_3051_deals_doc_type_region_param.py +++ /dev/null @@ -1,148 +0,0 @@ -"""#3051 п.3 + п.6: doc_type в deals и параметризация региона/типа документа на импорте. - -ЗАЧЕМ: для Москвы в источнике за 2024 лежат 30 627 ДДУ с медианой 112 743 ₽/м² против -107 005 ДКП с медианой 256 250 — это цены котлована. Тип сделки терялся при загрузке -(в deals не приезжал вовсе), а фильтры `region_code = 66` и `doc_type = 'ДКП'` стояли -литералами, поэтому регион 77 было не подключить, не правя код. Тесты фиксируют, что -параметры реально доезжают до bind-словаря, дефолты воспроизводят прежнее поведение, -а doc_type пишется в deals. - -Чисто-юнит: ассертим SQL-текст import_rosreestr_dkp (inspect.getsource) + текст миграции -288, плюс вызываем функцию с моком Session, чтобы увидеть фактические bind-параметры. -Живой FDW / БД не нужны. -""" - -import inspect -import os -import re -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock - -# Как в test_rosreestr_dedup_key.py: Settings требует DATABASE_URL до импорта. -os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") - -from app.services import scheduler - -_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" -_MIGRATION_288 = _SQL_DIR / "288_deals_doc_type.sql" -_IMPORT_SRC = inspect.getsource(scheduler.import_rosreestr_dkp) - - -# ── SQL-форма импорта ──────────────────────────────────────────────────────── - - -def test_import_filters_region_and_doc_type_via_bind_params() -> None: - """Оба фильтра — bind-параметры, литералов 66 / 'ДКП' в SQL больше нет.""" - assert "region_code = CAST(:region_code AS int)" in _IMPORT_SRC - assert "doc_type = ANY(CAST(:doc_types AS text[]))" in _IMPORT_SRC - assert "WHERE region_code = 66" not in _IMPORT_SRC - assert "AND doc_type = 'ДКП'" not in _IMPORT_SRC - # psycopg v3: ни один bind-параметр не приводится через :name::type. - assert not re.search(r":\w+::", _IMPORT_SRC) - - -def test_import_writes_doc_type_into_deals() -> None: - """doc_type едет из SELECT в список колонок INSERT, в VALUES и в ON CONFLICT.""" - assert "doc_type AS doc_type" in _IMPORT_SRC - assert "deal_date, doc_type" in _IMPORT_SRC - assert "CAST(:doc_type AS text)" in _IMPORT_SRC - # Переопубликованный квартал может исправить тип документа — обновляем. - assert "doc_type = EXCLUDED.doc_type" in _IMPORT_SRC - assert "IS DISTINCT FROM EXCLUDED.doc_type" in _IMPORT_SRC - assert '"doc_type": row["doc_type"]' in _IMPORT_SRC - - -def test_import_keeps_dedup_key_shape_unchanged() -> None: - """Ключ дедупа НЕ зависит от типа документа — иначе осиротели бы старые строки. - - id уникален в источнике сам по себе; префикс 'ros:dkp:' остаётся историческим - именем формата (см. миграцию 077, которая уже конвертировала все строки). - """ - assert "'ros:dkp:' || CAST(id AS text) AS dedup_hash" in _IMPORT_SRC - assert "ON CONFLICT (dedup_hash) DO UPDATE SET" in _IMPORT_SRC - - -# ── фактические bind-параметры (вызов с моком) ─────────────────────────────── - - -def _capture_select_params(params: dict[str, Any]) -> dict[str, Any]: - """Прогоняет import_rosreestr_dkp до первого SELECT из FDW и возвращает его binds. - - Мок Session: cleanup-DELETE и _resume_dkp_cursor получают пустые результаты, - первый батч — пустой список, поэтому цикл завершается сразу после SELECT. - """ - captured: list[dict[str, Any]] = [] - - def _execute(stmt: Any, binds: Any = None, *a: Any, **kw: Any) -> MagicMock: - sql = str(stmt) - if "FROM gendesign_rosreestr_deals" in sql and isinstance(binds, dict): - captured.append(binds) - result = MagicMock() - result.fetchall.return_value = [] - result.fetchone.return_value = None - result.mappings.return_value.all.return_value = [] - result.scalar.return_value = None - return result - - db = MagicMock() - db.execute.side_effect = _execute - - monkeys = { - "is_cancelled": False, - } - orig_is_cancelled = scheduler.runs_mod.is_cancelled - orig_mark_done = scheduler.runs_mod.mark_done - orig_heartbeat = scheduler.kit_runs.update_heartbeat - try: - scheduler.runs_mod.is_cancelled = lambda *a, **kw: monkeys["is_cancelled"] # type: ignore[assignment] - scheduler.runs_mod.mark_done = lambda *a, **kw: None # type: ignore[assignment] - scheduler.kit_runs.update_heartbeat = lambda *a, **kw: None # type: ignore[assignment] - scheduler.import_rosreestr_dkp(db, 1, params) - finally: - scheduler.runs_mod.is_cancelled = orig_is_cancelled # type: ignore[assignment] - scheduler.runs_mod.mark_done = orig_mark_done # type: ignore[assignment] - scheduler.kit_runs.update_heartbeat = orig_heartbeat # type: ignore[assignment] - - assert captured, "SELECT из gendesign_rosreestr_deals не выполнился" - return captured[0] - - -def test_defaults_preserve_current_production_behaviour() -> None: - """Без параметров — ровно прежний скоуп: Свердловская обл., только ДКП.""" - binds = _capture_select_params({}) - assert binds["region_code"] == 66 - assert binds["doc_types"] == ["ДКП"] - - -def test_params_override_region_and_doc_types() -> None: - """Москва + оба типа документа приходят из params, а не из кода.""" - binds = _capture_select_params({"region_code": 77, "doc_types": ["ДКП", "ДДУ"]}) - assert binds["region_code"] == 77 - assert binds["doc_types"] == ["ДКП", "ДДУ"] - - -def test_region_code_accepts_string_from_jsonb_params() -> None: - """default_params прилетают из jsonb — строковый регион не должен ломать CAST.""" - binds = _capture_select_params({"region_code": "77"}) - assert binds["region_code"] == 77 - assert isinstance(binds["region_code"], int) - - -# ── миграция 288 ───────────────────────────────────────────────────────────── - - -def test_migration_288_adds_column_backfills_and_sets_schedule_region() -> None: - """288: idempotent-колонка, бэкфилл только rosreestr-строк, явный регион в расписании.""" - assert _MIGRATION_288.is_file(), f"missing migration: {_MIGRATION_288}" - sql = _MIGRATION_288.read_text("utf-8") - assert "ALTER TABLE deals ADD COLUMN IF NOT EXISTS doc_type text" in sql - # Бэкфилл корректен ровно потому, что всё загруженное прошло фильтр ДКП. - assert "SET doc_type = 'ДКП'" in sql - assert "WHERE source = 'rosreestr'" in sql - assert "AND doc_type IS NULL" in sql - # Явный region_code в расписании вместо неявного дефолта в коде. - assert "UPDATE scrape_schedules" in sql - assert "'{\"region_code\": 66}'::jsonb" in sql - assert "source = 'rosreestr_dkp_import'" in sql - assert "BEGIN;" in sql and "COMMIT;" in sql diff --git a/tradein-mvp/backend/tests/test_rosreestr_dedup_key.py b/tradein-mvp/backend/tests/test_rosreestr_dedup_key.py index 145101c0..275a8a47 100644 --- a/tradein-mvp/backend/tests/test_rosreestr_dedup_key.py +++ b/tradein-mvp/backend/tests/test_rosreestr_dedup_key.py @@ -156,7 +156,7 @@ def test_migration_077_shared_filter_matches_live_import() -> None: ЕКБ-строки под EKB-only scope того времени; живой импорт расширен на всю Свердловскую область, поэтому city-фильтр из живого импорта СНЯТ намеренно; - region_code / doc_type: в живом импорте это ПАРАМЕТРЫ (#3051), их совпадение - с 077 проверяется через дефолты — см. тест ниже. + с 077 проверяется дефолтами params.get(..., 66) / ["ДКП"] в самом импорте. Остальные клозы обязаны совпадать байт-в-байт, иначе 077 конвертировал бы не тот набор строк. """ @@ -174,24 +174,6 @@ def test_migration_077_shared_filter_matches_live_import() -> None: assert "doc_type = 'ДКП'" in sql -def test_live_import_parametrized_region_and_doc_type_keep_077_defaults() -> None: - """#3051: регион и типы документов — bind-параметры, но ДЕФОЛТЫ = скоуп 077. - - Литералы `region_code = 66` / `doc_type = 'ДКП'` ушли из SQL живого импорта - (иначе регион 77 не подключить), но дефолты обязаны воспроизводить ровно тот - набор строк, который 077 когда-то backfill'ил, — иначе штатный прогон молча - сменил бы скоуп. - """ - # В SQL — параметры, не литералы. - assert "region_code = CAST(:region_code AS int)" in _IMPORT_SRC - assert "doc_type = ANY(CAST(:doc_types AS text[]))" in _IMPORT_SRC - assert "region_code = 66" not in _IMPORT_BODY - assert "doc_type = 'ДКП'" not in _IMPORT_BODY.split("params.get", 1)[-1].split("SELECT", 1)[0] - # Дефолты — прежнее поведение. - assert 'params.get("region_code", 66)' in _IMPORT_SRC - assert 'params.get("doc_types") or ["ДКП"]' in _IMPORT_SRC - - def test_live_import_dropped_ekb_city_filter() -> None: """Живой импорт БОЛЬШЕ не фильтрует по городу — Mera расширена на всю обл. 66.