From beb487c12e2661d952046c2d46eba1e252db87bf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 9 Sep 2026 02:23:47 +0300 Subject: [PATCH] =?UTF-8?q?test(#3051):=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D1=82=D1=80=D0=B5=D0=BA=D0=B0=20=C2=AB=D0=9C=D0=BE=D1=81?= =?UTF-8?q?=D0=BA=D0=B2=D0=B0=C2=BB=20=E2=80=94=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20=D0=94=D0=9A=D0=9F=20=D0=BF=D0=BE=2077,=20per-so?= =?UTF-8?q?urce=20=D1=87=D0=B5=D0=BA=D0=BF=D0=BE=D0=B8=D0=BD=D1=82,=20wild?= =?UTF-8?q?card-=D1=85=D0=B5=D0=BD=D0=B4=D0=BB=D0=B5=D1=80,=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D0=BE=D0=BD-=D1=81=D0=BA=D0=BE=D1=83=D0=BF=20?= =?UTF-8?q?=D0=94=D0=9A=D0=9F-=D0=BA=D0=BE=D1=80=D0=B8=D0=B4=D0=BE=D1=80?= =?UTF-8?q?=D0=B0,=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=2028?= =?UTF-8?q?9,=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20d?= =?UTF-8?q?eploy-=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тесты пишутся ПОСЛЕ живой проверки функционала (правило от 08.09): импорт по 77 — run 6460 (212 937 строк), регион-фильтр коридора — smoke до/после импорта. 14 тестов: SQL-текст через inspect (regex \s+ для многострочных клозов), чистые функции, мок db по образцу 2846, subprocess bash с урезанным PATH (падение от валидации, не от отсутствия docker). --- .../test_3051_dkp_corridor_region_scope.py | 98 +++++++++++++++++++ .../tests/test_3051_dkp_import_moscow.py | 93 ++++++++++++++++++ ...st_3051_migration_289_and_deploy_script.py | 91 +++++++++++++++++ .../tests/test_3051_region_registry.py | 18 ++++ .../tests/test_3168_backfill_cursor_resume.py | 47 +++++++++ .../test_scraper_kit_scheduler_parity.py | 21 ++++ 6 files changed, 368 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_3051_dkp_corridor_region_scope.py create mode 100644 tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py create mode 100644 tradein-mvp/backend/tests/test_3051_migration_289_and_deploy_script.py diff --git a/tradein-mvp/backend/tests/test_3051_dkp_corridor_region_scope.py b/tradein-mvp/backend/tests/test_3051_dkp_corridor_region_scope.py new file mode 100644 index 00000000..e5c9e1f3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_dkp_corridor_region_scope.py @@ -0,0 +1,98 @@ +"""#3051 PR-A — региональный скоуп ДКП-коридора (`_fetch_dkp_corridor` / потребители). + +region_code — ОБЯЗАТЕЛЬНЫЙ фильтр (не advisory, в отличие от city) в обоих ДКП-запросах +(street и city-wide widen): без него street ILIKE ловил бы одноимённую улицу ЧУЖОГО +региона. Мок-паттерн повторяет tests/test_dkp_corridor_as_of_2846.py — db.execute +диспатчится по подстрокам SQL-текста, без живого Postgres. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.estimator import _fetch_dkp_corridor + +_ADDRESS = "Москва, ул. Тверская, 1" + + +def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]: + return {"price_per_m2": ppm2, "deal_date": deal_date} + + +def _db_capturing( + street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None +) -> tuple[Any, list[tuple[str, dict[str, Any]]]]: + """Session-двойник, попутно копящий (sql, params) КАЖДОГО execute на deal_city_price_bands.""" + calls: list[tuple[str, dict[str, Any]]] = [] + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + sql = str(query) + result = MagicMock() + if "sber" in sql.lower(): + result.mappings.return_value.all.return_value = [] + elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql: + calls.append((sql, params or {})) + result.mappings.return_value.all.return_value = street_rows + elif "deal_city_price_bands" in sql: + calls.append((sql, params or {})) + result.mappings.return_value.all.return_value = city_rows or [] + else: + result.mappings.return_value.all.return_value = [] + return result + + db = MagicMock() + db.execute.side_effect = _execute + return db, calls + + +def test_corridor_binds_region_in_both_queries() -> None: + """street n=1 (< DKP_CORRIDOR_CITY_WIDE_MIN_N=3) → срабатывает city-wide widen — + + ОБА execute на deal_city_price_bands несут `d.region_code = CAST(:region_code AS int)` + и bind region_code=77 при явном kwarg; без kwarg — дефолт 66 (обратная совместимость, + сегодня все сделки в БД region_code=66). + """ + street_rows = [_deal(300_000, date(2026, 1, 1))] + city_rows = [_deal(290_000, date(2025, 10, 1)), _deal(295_000, date(2025, 11, 1))] + + db77, calls77 = _db_capturing(street_rows, city_rows) + raw = _fetch_dkp_corridor( + db77, address=_ADDRESS, rooms=2, area=50.0, city="нижний тагил", region_code=77 + ) + assert raw is not None + assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)" + assert len(calls77) == 2, "ожидались обе ветки: street + city-wide widen" + for sql, params in calls77: + assert "d.region_code = CAST(:region_code AS int)" in sql + assert params["region_code"] == 77 + + db66, calls66 = _db_capturing(street_rows, city_rows) + _fetch_dkp_corridor(db66, address=_ADDRESS, rooms=2, area=50.0, city="нижний тагил") + assert len(calls66) == 2 + for _sql, params in calls66: + assert params["region_code"] == 66 + + +def test_estimate_paths_never_pass_none_region() -> None: + """POST /estimate (estimate_quality) и GET rehydrate (load_estimate) обязаны + + резолвить region_code через regions_mod.DEFAULT_REGION_CODE fallback, а не оставить + None: NULL в bind-параметре `= NULL` не матчит ничего и молча обнулил бы фильтр + региона. Дешёвая source-проверка вместо тяжёлых end-to-end моков обоих путей. + """ + import inspect + + from app.api.v1.trade_in import load_estimate + from app.services.estimator import estimate_quality + + load_src = inspect.getsource(load_estimate) + estimate_src = inspect.getsource(estimate_quality) + + for src in (load_src, estimate_src): + assert "regions_mod.DEFAULT_REGION_CODE" in src + assert "region_code=target_region_code" in src diff --git a/tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py b/tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py new file mode 100644 index 00000000..87814bb9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py @@ -0,0 +1,93 @@ +"""#3051 п.3 — параметризация import_rosreestr_dkp по региону (Москва, region_code=77). + +Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через +inspect.getsource), тем же механизмом, что и test_rosreestr_dedup_key.py — без живого +FDW / DB-фикстуры. Многострочные клозы матчатся regex с `\\s+`, а не подстрокой: SQL +внутри text(...) переносится по строкам, точный пробельный рисунок хрупок. +""" + +from __future__ import annotations + +import inspect +import os +import re +from pathlib import Path +from unittest.mock import MagicMock + +# Импорт app.services.scheduler тянет app.core.config.Settings → требует DATABASE_URL. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.services import scheduler + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_289 = _SQL_DIR / "289_rosreestr_fdw_msk_columns_seed77.sql" + +_IMPORT_SRC = inspect.getsource(scheduler.import_rosreestr_dkp) + + +def test_case_on_canonical_city_bind() -> None: + """canonical_city — bind-параметр CASE-выражения (address/city/raw_payload), + + не Python if/else на конкретный код региона (#3051: одна ветка SQL решает и + для 66 (None), и для 77 ('Москва'), и для любого будущего региона). + """ + assert _IMPORT_SRC.count("CAST(:canonical_city AS text) IS NOT NULL") >= 3 + assert '"canonical_city": region.canonical_city' in _IMPORT_SRC + + +def test_city_filter_bypassed_only_with_canonical_city() -> None: + """WHERE-клоз: у региона С canonical_city city IS NOT NULL НЕ применяется — + + иначе для Москвы теряется ~10% строк с пустым city источника (округ/поселение + не всегда заполнен). + """ + pattern = re.compile( + r"\(\s*CAST\(:canonical_city AS text\) IS NOT NULL\s*" + r"OR \(city IS NOT NULL AND trim\(city\) <> ''\)\s*\)" + ) + assert pattern.search(_IMPORT_SRC), "не найден WHERE-клоз city IS NOT NULL bypass" + + +def test_raw_payload_jsonb_bind() -> None: + """raw_payload (jsonb) — исходные city/okato/quarter_cad_number/district для + + регионов с canonical_city; bind через CAST(... AS jsonb), не литерал. + """ + assert "CAST(:raw_payload AS jsonb)" in _IMPORT_SRC + assert "raw_payload = EXCLUDED.raw_payload" in _IMPORT_SRC + assert "IS DISTINCT FROM EXCLUDED.raw_payload" in _IMPORT_SRC + assert "json.dumps" in _IMPORT_SRC + + +def test_doc_types_param_default_dkp() -> None: + """doc_types — параметр (было: литерал 'ДКП'), дефолт ['ДКП'] сохраняет прежнее + + поведение (только вторичка). + """ + assert "ANY(CAST(:doc_types AS text[]))" in _IMPORT_SRC + assert 'params.get("doc_types") or ["ДКП"]' in _IMPORT_SRC + + +def test_unknown_region_raises() -> None: + """region_code без записи в REGIONS → ValueError ДО любого db.execute — прогон + + не должен успеть тронуть БД с неизвестным региональным контекстом. + """ + db = MagicMock() + with pytest.raises(ValueError, match="99"): + scheduler.import_rosreestr_dkp(db, run_id=1, params={"region_code": 99}) + db.execute.assert_not_called() + + +def test_dkp_source_for_region() -> None: + """66 → байт-в-байт прежнее имя _DKP_SOURCE; 77 → суффиксное имя, совпадающее + + с source seed-строки миграции 289 (изоляция чекпоинтов между регионами). + """ + assert scheduler._dkp_source_for_region(66) == scheduler._DKP_SOURCE + assert scheduler._dkp_source_for_region(77) == "rosreestr_dkp_import_77" + + sql = _MIGRATION_289.read_text("utf-8") + assert "'rosreestr_dkp_import_77'" in sql, "seed-source миграции 289 разъехался с кодом" diff --git a/tradein-mvp/backend/tests/test_3051_migration_289_and_deploy_script.py b/tradein-mvp/backend/tests/test_3051_migration_289_and_deploy_script.py new file mode 100644 index 00000000..082b154a --- /dev/null +++ b/tradein-mvp/backend/tests/test_3051_migration_289_and_deploy_script.py @@ -0,0 +1,91 @@ +"""#3051 п.3 — миграция 289 (FDW-колонки + disabled seed 77) и bash-путь import-rosreestr.sh. + +(a) Статические ассерты содержимого миграции 289 — seed выключен, region_code=77 в +default_params, идемпотентность (ON CONFLICT DO NOTHING + 3× ADD COLUMN IF NOT EXISTS). + +(b) Bash-скрипт — НЕ region-generic Python-путь (см. докстринг скрипта): валидация +REGION_CODE/DOC_TYPE обязана отбить инъекцию ДО первого docker-вызова. Проверяем через +subprocess с урезанным PATH (без `docker`) — падение должно случиться на валидации +(`[[ ... =~ ... ]]`/bash builtins), а не на "docker: command not found", иначе тест +ничего не защищает. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +_BACKEND_DIR = Path(__file__).resolve().parents[1] +_TRADEIN_DIR = _BACKEND_DIR.parent +_MIGRATION_289 = _BACKEND_DIR / "data" / "sql" / "289_rosreestr_fdw_msk_columns_seed77.sql" +_DEPLOY_SCRIPT = _TRADEIN_DIR / "deploy" / "import-rosreestr.sh" + + +# ── (a) миграция 289 — статика ─────────────────────────────────────────────────── + + +def test_migration_289_seed_disabled_with_region_code_77() -> None: + sql = _MIGRATION_289.read_text("utf-8") + assert "'rosreestr_dkp_import_77',\n false," in sql, ( + "seed-строка обязана быть enabled=false (включение — отдельное решение)" + ) + assert '"region_code": 77' in sql + assert "ON CONFLICT (source) DO NOTHING" in sql + # Реальные DDL-строки (отступ 4 пробела) — не считая упоминание "× 3" в шапке-комментарии. + assert sql.count("\n ADD COLUMN IF NOT EXISTS") == 3 + + +# ── (b) деплой-скрипт — валидация раньше docker ────────────────────────────────── + +_BASH = shutil.which("bash") + + +@pytest.mark.skipif(_BASH is None, reason="bash недоступен в PATH (нет git-bash/WSL)") +def test_invalid_region_code_rejected_before_any_docker_call(tmp_path: Path) -> None: + """REGION_CODE с инъекцией → rc=1, сообщение валидации, PATH без docker — до + + первой docker-команды (line 50) скрипт не доходит вовсе. + """ + empty_bin = tmp_path / "empty_bin" + empty_bin.mkdir() + env = dict(os.environ) + env["PATH"] = str(empty_bin) + env["REGION_CODE"] = "77;drop table x" + + result = subprocess.run( + [_BASH, str(_DEPLOY_SCRIPT)], + env=env, + capture_output=True, + text=True, + timeout=15, + ) + + assert result.returncode == 1 + assert "REGION_CODE" in result.stderr + assert result.stdout == "", "stdout пуст — значит ни одна docker-команда не выполнилась" + + +@pytest.mark.skipif(_BASH is None, reason="bash недоступен в PATH (нет git-bash/WSL)") +def test_invalid_doc_type_rejected_before_any_docker_call(tmp_path: Path) -> None: + """DOC_TYPE с кавычкой → rc=1, сообщение валидации, до docker не доходит.""" + empty_bin = tmp_path / "empty_bin" + empty_bin.mkdir() + env = dict(os.environ) + env["PATH"] = str(empty_bin) + env["DOC_TYPE"] = "x'y" + + result = subprocess.run( + [_BASH, str(_DEPLOY_SCRIPT)], + env=env, + capture_output=True, + text=True, + timeout=15, + ) + + assert result.returncode == 1 + assert "DOC_TYPE" in result.stderr + assert result.stdout == "" diff --git a/tradein-mvp/backend/tests/test_3051_region_registry.py b/tradein-mvp/backend/tests/test_3051_region_registry.py index 20a3172f..eb3de64d 100644 --- a/tradein-mvp/backend/tests/test_3051_region_registry.py +++ b/tradein-mvp/backend/tests/test_3051_region_registry.py @@ -127,6 +127,24 @@ def test_region_by_city_folds_and_resolves() -> None: assert regions.region_by_city(None) is None +def test_canonical_city_and_region_for_point() -> None: + """canonical_city: 66 None (byte-for-byte прежнее) / 77 'Москва' (Росреестр отдаёт + + округ/поселение, не город). region_for_point резолвит регион по bbox_region — + точка в Москве → 77, в ЕКБ → 66, вне обоих (Пермь) → None. + """ + assert regions.REGIONS[66].canonical_city is None + assert regions.REGIONS[77].canonical_city == "Москва" + + moscow = regions.region_for_point(55.75, 37.62) + assert moscow is not None and moscow.code == 77 + + ekb = regions.region_for_point(56.8300, 60.6000) + assert ekb is not None and ekb.code == 66 + + assert regions.region_for_point(58.01, 56.25) is None # Пермь — вне охвата обоих + + def test_unsupported_tier_degrades_loudly_not_silently() -> None: """У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой формулировкой, а не молчаливое «посчитаем без источника».""" diff --git a/tradein-mvp/backend/tests/test_3168_backfill_cursor_resume.py b/tradein-mvp/backend/tests/test_3168_backfill_cursor_resume.py index eedd35ba..c2bcf839 100644 --- a/tradein-mvp/backend/tests/test_3168_backfill_cursor_resume.py +++ b/tradein-mvp/backend/tests/test_3168_backfill_cursor_resume.py @@ -34,6 +34,7 @@ test_resume_continues_from_saved_last_id (last_id == 123456 не совпада from __future__ import annotations +import inspect import json import os from typing import Any @@ -139,6 +140,52 @@ def test_no_previous_run_starts_from_zero() -> None: assert verdict["resume_from"] is None +# ── 1b. Курсор — ПЕР РЕГИОН (#3051 п.3): source изолирует чекпоинты 66/77 ─────────── + + +class _SourceCapturingDb: + """Двойник: запоминает `source`-bind ПОСЛЕДНЕГО SELECT-запроса (_DKP_RESUME_CANDIDATE_SQL). + + Отличает SELECT (несёт 'source' в params) от heartbeat-UPDATE (несёт 'counters') + тем же приёмом, что _FakeDb выше. + """ + + def __init__(self, row: Any) -> None: + self.row = row + self.select_params: dict[str, Any] | None = None + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params is not None and "source" in params: + self.select_params = params + return MagicMock(fetchone=lambda: self.row) + return MagicMock() + + def commit(self) -> None: + pass + + +def test_resume_uses_region_source() -> None: + """source= (#3051 п.3) — курсор региона 77 изолирован от курсора региона 66: + + явный source попадает bind-параметром в SELECT-кандидата; вызов без аргумента + сохраняет прежнее имя _DKP_SOURCE (регион 66, обратная совместимость). Сам + call site (import_rosreestr_dkp) обязан передавать именно региональный source, + не полагаться на дефолт молча. + """ + db_77 = _SourceCapturingDb(None) + sched._resume_dkp_cursor(db_77, run_id=1, source="rosreestr_dkp_import_77") + assert db_77.select_params is not None + assert db_77.select_params["source"] == "rosreestr_dkp_import_77" + + db_default = _SourceCapturingDb(None) + sched._resume_dkp_cursor(db_default, run_id=2) + assert db_default.select_params is not None + assert db_default.select_params["source"] == sched._DKP_SOURCE + + import_src = inspect.getsource(sched.import_rosreestr_dkp) + assert "source=dkp_source" in import_src, "call site не передаёт региональный source" + + # ── 2. Курсор старше потолка не подхватывается — прогон стартует заново ───────────── diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index efe3585a..cde8e7c3 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -215,6 +215,27 @@ def test_deactivate_stale_wildcard_prefix() -> None: assert h_avito is h_yandex is h_cian # один и тот же handler на всё семейство +def test_rosreestr_dkp_wildcard_resolves() -> None: + """#3051 п.3: 'rosreestr_dkp_import_77' (Москва) резолвится через wildcard-запись + + 'rosreestr_dkp_import_*' — в реестре НЕТ отдельного exact-match ключа под + каждый region_code-суффикс (тот же механизм, что deactivate_stale_*/ + avito_city_sweep_*). Точное имя БЕЗ суффикса ('rosreestr_dkp_import', регион 66) + резолвится СВОЕЙ exact-match записью, не wildcard'ом. + """ + from app.services.product_handlers import _job_rosreestr_dkp, build_product_handlers + + real_registry = build_registry(build_product_handlers(ctx=None)) # type: ignore[arg-type] + + h77 = resolve_handler("rosreestr_dkp_import_77", real_registry) + assert h77 is real_registry["rosreestr_dkp_import_*"] + assert h77 is not None and h77.job is _job_rosreestr_dkp + + h_exact = resolve_handler("rosreestr_dkp_import", real_registry) + assert h_exact is real_registry["rosreestr_dkp_import"] + assert h_exact is not real_registry["rosreestr_dkp_import_*"] + + # ── 2. _claim_run advisory-lock parity ───────────────────────────────────────