test(tradein): тесты трека «Москва» — импорт ДКП по 77, чекпоинт per-source, wildcard-хендлер, регион-скоуп коридора, миграция 289, deploy-скрипт (#3051) #3430
6 changed files with 368 additions and 0 deletions
|
|
@ -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
|
||||
93
tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py
Normal file
93
tradein-mvp/backend/tests/test_3051_dkp_import_moscow.py
Normal 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 разъехался с кодом"
|
||||
|
|
@ -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 == ""
|
||||
|
|
@ -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:
|
||||
"""У Москвы нет ни одного тира обогащения — и это ЯВНЫЙ факт с готовой
|
||||
формулировкой, а не молчаливое «посчитаем без источника»."""
|
||||
|
|
|
|||
|
|
@ -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. Курсор старше потолка не подхватывается — прогон стартует заново ─────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue