All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 57s
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
A. ДОМ.РФ year backfill: авторитетный commission_year больше не экранируется невозможным существующим year_built. Валидное существующее значение выигрывает (COALESCE-семантика #2013 сохранена), но NULL/impossible (< 1850, > текущий+2, 0) заменяется валидным commission_year → zhkh_year. parse_int_field получил sanity-гейт [min,max] — мусорный commission_year не попадает в staging (7 таких строк в текущем staging). B. propagate_listings_year: добавлен link-consistency guard. Раньше копировал houses.year_built на listings по house_id_fk без проверки — перепривязанный FK впрыскивал чужой когортный год. Теперь пропагация только если координаты объявления в пределах 500м от геометрии дома (ST_DWithin), либо (без коорд.) консервативный address-фоллбек по short_address. Live: блокирует 1 из 10 текущих кандидатов (>500м mislink). C. rosreestr_dkp_import: per-row INSERT-ошибки отделены от dedup-skip. Раньше except инкрементил тот же batch_skipped, что и легитимный ON CONFLICT — сбой маскировался под дедуп и run рапортовал success. Отдельный rows_errored + гейт по доле ошибок (> 5% → run FAILED, не silent-green). D. rosreestr_dkp_import: ON CONFLICT DO NOTHING → DO UPDATE изменяемых сырых фактов Росреестра (price/area/rooms/floor/year/deal_date/...), чтобы исправленный/переопубликованный квартал обновлялся. Обогащение (lat/lon/geom/geocode_tried_at и пр.) не в SET-списке — не затирается. IS DISTINCT FROM guard сохраняет идемпотентность resume; RETURNING (xmax=0) отличает insert от update (rows_inserted vs rows_updated).
184 lines
10 KiB
Python
184 lines
10 KiB
Python
"""Unit-тесты для формата dedup_hash rosreestr ДКП-сделок (#576).
|
||
|
||
До #576 dedup_hash хранил md5('ros:dkp:' || id) — collision-prone, не human-readable,
|
||
дедуп не восстановить из значения. После — плоский натуральный ключ 'ros:dkp:N'
|
||
(инъективный, zero-collision, обратимый в исходный rosreestr id).
|
||
|
||
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через inspect.getsource),
|
||
плюс содержимое backfill-миграции 077 — без живого FDW / DB-фикстуры.
|
||
"""
|
||
|
||
import inspect
|
||
import os
|
||
from pathlib import Path
|
||
|
||
# Импорт app.services.scheduler тянет app.core.config.Settings → требует DATABASE_URL.
|
||
# Ставим заглушку ДО импорта (как в test_scheduler.py) — тест чисто-статический, БД не трогает.
|
||
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_077 = _SQL_DIR / "077_dedup_hash_plain_key_backfill.sql"
|
||
|
||
# Источник функции импорта — читаем один раз.
|
||
_IMPORT_SRC = inspect.getsource(scheduler.import_rosreestr_dkp)
|
||
# Тело без docstring: docstring упоминает старое md5-выражение в пояснительных целях,
|
||
# поэтому negative-ассерты («md5 больше нет») гоняем по коду, а не по докстрингу.
|
||
_IMPORT_BODY = _IMPORT_SRC.split('"""', 2)[-1]
|
||
|
||
|
||
def test_import_uses_plain_natural_key_not_md5() -> None:
|
||
"""SELECT-выражение dedup_hash — плоский ключ 'ros:dkp:' || id, без md5()."""
|
||
# Плоский натуральный ключ присутствует как SELECT-выражение dedup_hash.
|
||
assert "'ros:dkp:' || CAST(id AS text) AS dedup_hash" in _IMPORT_SRC
|
||
# md5-обёртки натурального ключа в коде больше нет (старое выражение #549).
|
||
assert "md5('ros:dkp:'" not in _IMPORT_BODY
|
||
assert "md5(" not in _IMPORT_BODY
|
||
|
||
|
||
def test_import_populates_source_id() -> None:
|
||
"""Новые строки сохраняют исходный rosreestr id в source_id (дедуп переустанавливаем)."""
|
||
# source_id попадает в SELECT, в список колонок INSERT и в params.
|
||
assert "AS source_id_src" in _IMPORT_SRC
|
||
assert "source, dedup_hash, source_id," in _IMPORT_SRC
|
||
assert "CAST(:source_id AS text)" in _IMPORT_SRC
|
||
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
|
||
|
||
|
||
def test_import_uses_on_conflict_do_update_for_corrections() -> None:
|
||
"""Fix D: дедуп по dedup_hash, но ON CONFLICT DO UPDATE (не DO NOTHING) —
|
||
|
||
исправленный/переопубликованный квартал обновляет сырые факты Росреестра.
|
||
"""
|
||
assert "ON CONFLICT (dedup_hash) DO UPDATE SET" in _IMPORT_SRC
|
||
assert "DO NOTHING" not in _IMPORT_BODY
|
||
# Обновляются изменяемые сырые факты...
|
||
for col in (
|
||
"address = EXCLUDED.address",
|
||
"region_code = EXCLUDED.region_code",
|
||
"city = EXCLUDED.city",
|
||
"rooms = EXCLUDED.rooms",
|
||
"area_m2 = EXCLUDED.area_m2",
|
||
"floor = EXCLUDED.floor",
|
||
"year_built = EXCLUDED.year_built",
|
||
"price_rub = EXCLUDED.price_rub",
|
||
"price_per_m2 = EXCLUDED.price_per_m2",
|
||
"deal_date = EXCLUDED.deal_date",
|
||
):
|
||
assert col in _IMPORT_SRC, f"missing DO UPDATE SET column: {col!r}"
|
||
# ...но идемпотентность через IS DISTINCT FROM (неизменные строки не трогаются).
|
||
assert "IS DISTINCT FROM EXCLUDED.price_rub" in _IMPORT_SRC
|
||
# xmax-дискриминатор insert vs update.
|
||
assert "RETURNING (xmax = 0)" in _IMPORT_SRC
|
||
|
||
|
||
def test_import_do_update_never_overwrites_enrichment_columns() -> None:
|
||
"""Fix D: обогащение (lat/lon/geom/geocode/cadastral_number...) не в SET-списке.
|
||
|
||
Эти колонки заполняются пост-импортом (geocode_deals_*) — повторный импорт не
|
||
должен их затирать. Отсутствие в SET DO UPDATE гарантирует сохранность.
|
||
"""
|
||
conflict_clause = _IMPORT_SRC.split("ON CONFLICT (dedup_hash) DO UPDATE SET", 1)[1]
|
||
set_and_where = conflict_clause.split("RETURNING", 1)[0]
|
||
for enrich in (
|
||
"lat =",
|
||
"lon =",
|
||
"geom =",
|
||
"geocode_tried_at =",
|
||
"cadastral_number =",
|
||
"total_floors =",
|
||
"house_type =",
|
||
):
|
||
assert enrich not in set_and_where, f"enrichment column leaked into UPDATE: {enrich!r}"
|
||
|
||
|
||
def test_import_separates_errors_from_dedup_skips() -> None:
|
||
"""Fix C: INSERT-ошибки считаются отдельным счётчиком, не как dedup-skip."""
|
||
assert '"rows_errored": 0' in _IMPORT_SRC
|
||
assert '"rows_updated": 0' in _IMPORT_SRC
|
||
assert "batch_errored += 1" in _IMPORT_SRC
|
||
# Per-row except-ветка (после лога "INSERT failed", до конца батча) инкрементит
|
||
# ТОЛЬКО errored — больше НЕ skip (это был баг silent-green: ошибки маскировались
|
||
# под дедуп).
|
||
per_row_except = _IMPORT_SRC.split("INSERT failed", 1)[1].split("last_id = batch_max_id", 1)[0]
|
||
assert "batch_errored += 1" in per_row_except
|
||
assert "batch_skipped += 1" not in per_row_except
|
||
# Порог ошибок → run failed, а не silent-green.
|
||
assert "DKP_IMPORT_ERROR_RATE_THRESHOLD" in _IMPORT_SRC
|
||
assert "marking run failed" in _IMPORT_SRC
|
||
|
||
|
||
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
|
||
"""psycopg v3: параметры приводятся через CAST(:x AS type), никогда :x::type.
|
||
|
||
Литеральные ::-касты на колонках FDW (round(deal_price)::bigint и т.п.) допустимы —
|
||
запрет касается только :param::type, который psycopg v3 ломает.
|
||
"""
|
||
import re
|
||
|
||
# Ни один bind-параметр (:name) не должен использовать ::type.
|
||
assert not re.search(r":\w+::", _IMPORT_SRC)
|
||
# Параметры приводятся через CAST(:param AS type).
|
||
assert "CAST(:since AS date)" in _IMPORT_SRC
|
||
assert "CAST(:last_id AS bigint)" in _IMPORT_SRC
|
||
assert "CAST(:source_id AS text)" in _IMPORT_SRC
|
||
|
||
|
||
# ── backfill-миграция 077 ────────────────────────────────────────────────────
|
||
|
||
|
||
def test_migration_077_exists() -> None:
|
||
assert _MIGRATION_077.is_file(), f"missing migration: {_MIGRATION_077}"
|
||
|
||
|
||
def test_migration_077_converts_md5_to_plain_key() -> None:
|
||
"""077 матчит существующие строки по md5 и пишет плоский ключ + source_id."""
|
||
sql = _MIGRATION_077.read_text("utf-8")
|
||
# Восстанавливаем id из FDW по тому же md5, что писался раньше.
|
||
assert "md5('ros:dkp:' || CAST(id AS text))" in sql
|
||
assert "FROM gendesign_rosreestr_deals" in sql
|
||
# Целевые значения — плоский ключ и source_id.
|
||
assert "dedup_hash = 'ros:dkp:' || CAST(src.id AS text)" in sql
|
||
assert "source_id = CAST(src.id AS text)" in sql
|
||
# Только rosreestr-строки, матч по старому md5-значению.
|
||
assert "d.source = 'rosreestr'" in sql
|
||
assert "d.dedup_hash = src.h" in sql
|
||
# Транзакционно.
|
||
assert "BEGIN;" in sql and "COMMIT;" in sql
|
||
|
||
|
||
def test_migration_077_shared_filter_matches_live_import() -> None:
|
||
"""Дедуп-релевантные фильтры src CTE миграции 077 совпадают с живым импортом.
|
||
|
||
Исключение — city ILIKE (см. test_live_import_dropped_ekb_city_filter ниже):
|
||
077 backfill'ил ЕКБ-строки под EKB-only scope того времени; живой импорт расширен
|
||
на всю Свердловскую область (region_code=66, все города), поэтому city-фильтр из
|
||
живого импорта СНЯТ намеренно. Остальные клозы обязаны совпадать байт-в-байт,
|
||
иначе 077 конвертировал бы не тот набор строк.
|
||
"""
|
||
sql = _MIGRATION_077.read_text("utf-8")
|
||
for clause in (
|
||
"region_code = 66",
|
||
"realestate_type_code = '002001003000'",
|
||
"area BETWEEN 18 AND 200",
|
||
"deal_price BETWEEN 1000000 AND 100000000",
|
||
"street IS NOT NULL AND trim(street) <> ''",
|
||
"doc_type = 'ДКП'",
|
||
):
|
||
assert clause in sql, f"missing filter clause in migration: {clause!r}"
|
||
assert clause in _IMPORT_SRC, f"missing filter clause in import: {clause!r}"
|
||
|
||
|
||
def test_live_import_dropped_ekb_city_filter() -> None:
|
||
"""Живой импорт БОЛЬШЕ не фильтрует по городу — Mera расширена на всю обл. 66.
|
||
|
||
Историческая миграция 077 (уже применена на прод, трогать нельзя) сохраняет свой
|
||
EKB-only city-фильтр; живой импорт — нет. Guard против случайного возврата
|
||
ЕКБ-скоупа, который снова отрезал бы +47k не-ЕКБ сделок.
|
||
"""
|
||
sql = _MIGRATION_077.read_text("utf-8")
|
||
# 077 — исторический EKB-scope, city-фильтр там остаётся неизменным.
|
||
assert "city ILIKE '%катеринбург%'" in sql
|
||
# Живой импорт city-фильтр снял намеренно (region_code=66 = вся область).
|
||
assert "ILIKE '%катеринбург%'" not in _IMPORT_SRC
|