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