gendesign/tradein-mvp/backend/tests/test_rosreestr_dedup_key.py
bot-backend 6487813ec5
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
fix(tradein/ingest): 4 data-integrity дефекта в бэкфилле годов и импорте ДКП
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).
2026-07-12 22:12:31 +03:00

184 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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