gendesign/tradein-mvp/backend/tests/test_rosreestr_dedup_key.py
bot-backend fcf5887225
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 5m12s
merge(#3051): main (#3421) в ветку импорта по региону — московская дельта поверх region_code/doc_type
#3421 въехал в main параллельно с той же миграцией 288 (deals.doc_type,
параметры region_code/doc_types). Разрешение: 288 — целиком версия main;
наша дельта (FDW-колонки okato/quarter_cad_number/district, выключенный seed
rosreestr_dkp_import_77) переехала в 289. scheduler.py — doc_types из main +
canonical_city-маппинг/raw_payload/per-source чекпоинт. deploy-скрипт —
валидация REGION_CODE и DOC_TYPE (интерполируются в SQL текстом).
2026-09-08 23:19:47 +03:00

201 lines
11 KiB
Python
Raw Permalink 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 того времени; живой импорт расширен на всю
Свердловскую область, поэтому city-фильтр из живого импорта СНЯТ намеренно;
- region_code / doc_type: в живом импорте это ПАРАМЕТРЫ (#3051), их совпадение
с 077 проверяется дефолтами params.get(..., 66) / ["ДКП"] в самом импорте.
Остальные клозы обязаны совпадать байт-в-байт, иначе 077 конвертировал бы не тот
набор строк.
"""
sql = _MIGRATION_077.read_text("utf-8")
assert "region_code = 66" in sql, "migration 077 must keep its historical literal"
for clause in (
"realestate_type_code = '002001003000'",
"area BETWEEN 18 AND 200",
"deal_price BETWEEN 1000000 AND 100000000",
"street IS NOT NULL AND trim(street) <> ''",
):
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}"
# Исторические литералы 077 остаются на месте (миграция применена, её не правят).
assert "region_code = 66" in sql
assert "doc_type = 'ДКП'" in sql
def test_live_import_region_code_is_bind_param() -> None:
"""#3051 п.3: живой импорт параметризован по региону, литерала 66 в SQL нет.
До #3051 живой импорт хардкодил `WHERE region_code = 66` — единственный регион
покрытия. С параметризацией region_code приходит из params (default 66 — обратная
совместимость), в SQL идёт bind-параметром через CAST, не литералом.
"""
assert "region_code = CAST(:region_code AS int)" in _IMPORT_SRC
assert "region_code = 66" not in _IMPORT_BODY
assert 'params.get("region_code", 66)' in _IMPORT_SRC
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