gendesign/tradein-mvp/backend/tests/test_rosreestr_dedup_key.py
Light1YT b51893f7c3 fix(tradein): plain natural-key dedup_hash for rosreestr deals (#576)
rosreestr ДКП deals used dedup_hash = md5('ros:dkp:' || id) — a hash of
an already-unique natural key. The md5 is collision-prone (theoretically),
not human-readable, and irreversible (can't recover the source id from it,
which mattered because deals.source_id was never populated).

Replace with the plain natural key 'ros:dkp:' || id (injective, zero
collision, reversible) and populate deals.source_id going forward, in both
the live scheduler import (import_rosreestr_dkp) and the legacy
deploy/import-rosreestr.sh.

Migration 077 backfills the existing 49,791 rows: recovers the original id
from FDW foreign table gendesign_rosreestr_deals by matching the old md5,
then writes the plain key + source_id. Idempotent; plain keys and md5 hex
are format-disjoint so no transient UNIQUE violation on deals_dedup_hash_key.
Verified via prod dry-run (ROLLBACK): UPDATE 49791, 0 md5 remaining, 3.85s.

Closes #576
2026-05-29 11:55:15 +05:00

105 lines
5.7 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_keeps_on_conflict_dedup_hash() -> None:
"""Дедуп по-прежнему через ON CONFLICT (dedup_hash) DO NOTHING."""
assert "ON CONFLICT (dedup_hash) DO NOTHING" 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_filter_matches_live_import() -> None:
"""Фильтр src CTE байт-в-байт совпадает с живым импортом → все строки сконвертируются."""
sql = _MIGRATION_077.read_text("utf-8")
for clause in (
"region_code = 66",
"city ILIKE '%катеринбург%'",
"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}"