test(tradein): тесты трека «Москва» — импорт ДКП по 77, чекпоинт per-source, wildcard-хендлер, регион-скоуп коридора, миграция 289, deploy-скрипт (#3051) #3430

Merged
lekss361 merged 1 commit from test/3051-moscow-track-tests into main 2026-09-08 23:30:22 +00:00
6 changed files with 368 additions and 0 deletions

View file

@ -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

View file

@ -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 разъехался с кодом"

View file

@ -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 == ""

View file

@ -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:
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
формулировкой, а не молчаливое «посчитаем без источника»."""

View file

@ -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. Курсор старше потолка не подхватывается — прогон стартует заново ─────────────

View file

@ -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 ───────────────────────────────────────