"""ON CONFLICT DO NOTHING в land_reservation обязан реально ловить дубли (#2464). `_UPSERT_NO_ACT_SQL` (workers/tasks/izyatie_ocr_ingest.py) заканчивается `ON CONFLICT DO NOTHING`, а единственный подходящий констрейнт был `UNIQUE (cad_num, act_number)` с обычной NULL-семантикой. В Postgres NULL != NULL, поэтому у записей БЕЗ номера акта конфликт не наступал никогда — каждый недельный прогон вставлял копию. Замер прода 20.08.2026 до правки: 297 строк, все с `act_number IS NULL`, 27 групп с дублями, до 11 копий, 270 лишних строк — 91 % таблицы. Миграция 189 дедуплицирует таблицу и пересоздаёт констрейнт как `UNIQUE NULLS NOT DISTINCT`. Здесь проверяется САМ МЕХАНИЗМ на временной копии: с новой семантикой повторная вставка — no-op, со старой — дубль. Второе утверждение обязательно: без него тест не отличить от «оно и так работало». Тест герметичный: таблицы временные, боевые данные не читаются и не меняются. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import re from pathlib import Path import pytest from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker _MIGRATION = ( Path(__file__).resolve().parents[3] / "data" / "sql" / "189_land_reservation_nulls_not_distinct.sql" ) def _dsn() -> str: raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" ) return ( raw if raw.startswith("postgresql+") else raw.replace("postgresql://", "postgresql+psycopg://") ) def _db_reachable() -> tuple[bool, str]: try: eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) with eng.connect() as c: c.execute(text("SELECT 1")) return True, "" except Exception as exc: return False, str(exc) _DB_OK, _DB_ERR = _db_reachable() pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") _TABLE = """ CREATE TEMP TABLE land_reservation ( id bigserial PRIMARY KEY, cad_num text NOT NULL, act_number text, doc_url text, reservation_kind text, is_active boolean DEFAULT true, fetched_at timestamptz DEFAULT now() ) ON COMMIT DROP; """ _INSERT = """ INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind) VALUES (:cad, :act, :url, 'изъятие') ON CONFLICT DO NOTHING """ @pytest.fixture def db(): engine = create_engine(_dsn()) session = sessionmaker(bind=engine)() try: session.execute(text(_TABLE)) n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar() assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)" yield session finally: session.rollback() session.close() engine.dispose() def _add_constraint(db, nulls_not_distinct: bool) -> None: kind = "UNIQUE NULLS NOT DISTINCT" if nulls_not_distinct else "UNIQUE" db.execute( text(f"ALTER TABLE land_reservation ADD CONSTRAINT uq_t {kind} (cad_num, act_number)") ) def _insert_twice(db) -> int: for _ in range(2): db.execute( text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"} ) return int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) def test_nulls_not_distinct_deduplicates(db) -> None: """С новой семантикой повторная вставка act-less записи — no-op.""" _add_constraint(db, nulls_not_distinct=True) assert _insert_twice(db) == 1, "дубль всё равно вставился" def test_plain_unique_does_not_deduplicate(db) -> None: """Фальсификация: со СТАРЫМ констрейнтом дубль обязан появиться. Без этой проверки зелёный тест выше неотличим от «оно и так работало». """ _add_constraint(db, nulls_not_distinct=False) assert _insert_twice(db) == 2, ( "обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает" ) def test_records_with_act_number_still_deduplicate(db) -> None: """Контроль: записи С номером акта дедуплицировались и раньше — не сломали.""" _add_constraint(db, nulls_not_distinct=True) for _ in range(2): db.execute(text(_INSERT), {"cad": "66:41:1", "act": "12-АК", "url": "https://x/1.pdf"}) assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 1 def test_different_parcels_are_not_collapsed(db) -> None: """Контроль: разные участки без номера акта остаются разными строками. Ловит «починку» через слишком широкий ключ. """ _add_constraint(db, nulls_not_distinct=True) for cad in ("66:41:1", "66:41:2", "66:41:3"): db.execute(text(_INSERT), {"cad": cad, "act": None, "url": "https://x/z.pdf"}) assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 3 def test_migration_dedup_statement_matches_the_key(db) -> None: """DELETE в миграции обязан чистить ровно по ключу констрейнта. Расхождение ключа дедупа и ключа констрейнта означало бы, что после DELETE констрейнт всё равно не создастся — миграция упала бы на проде. """ sql = _MIGRATION.read_text() assert "UNIQUE NULLS NOT DISTINCT (cad_num, act_number)" in sql delete_stmt = re.search(r"DELETE FROM land_reservation.*?;", sql, re.S) assert delete_stmt is not None, "в миграции нет DELETE — дедуп не выполняется" body = delete_stmt.group(0) assert "a.cad_num = b.cad_num" in body, "дедуп не по cad_num" assert "a.act_number IS NULL" in body and "b.act_number IS NULL" in body, ( "дедуп затрагивает записи С номером акта — они и так были уникальны" ) assert "a.id > b.id" in body, "не задан выживающий (минимальный id)" def test_migration_body_runs_on_a_prod_shaped_replica(db) -> None: """Репетиция миграции: 11 копий → 1 строка, констрейнт создаётся. Сильнее проверки регулярками: исполняются РЕАЛЬНЫЕ выражения из файла миграции. Если DELETE чистит не по тому ключу, ADD CONSTRAINT здесь же и упадёт — как упал бы на проде. """ # Засев как на проде: одна группа, 11 точных копий, плюс соседний участок. for _ in range(11): db.execute( text( "INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)" " VALUES ('66:41:0303004:22', NULL, 'https://x/y.pdf', 'изъятие')" ) ) db.execute( text( "INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)" " VALUES ('66:41:0206032:8499', NULL, 'https://x/z.pdf', 'изъятие')" ) ) assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 12 sql = _MIGRATION.read_text() body = sql[sql.index("BEGIN;") + len("BEGIN;") : sql.rindex("COMMIT;")] # Комментарии снимаем ДО разбиения на выражения — иначе точка с запятой внутри # комментария разрежет SQL посередине. Обе ловушки этот тест уже ловил на себе: # сперва пропуск куска, начинающегося с «--» (потерялся DELETE, репетиция упала # на ADD CONSTRAINT), затем «;» в тексте комментария. code = "\n".join( ln for ln in body.splitlines() if ln.strip() and not ln.lstrip().startswith("--") ) for chunk in code.split(";"): stmt = chunk.strip() if stmt: db.execute(text(stmt)) rows = db.execute( text("SELECT cad_num, count(*) FROM land_reservation GROUP BY 1 ORDER BY 1") ).all() assert [(r[0], r[1]) for r in rows] == [ ("66:41:0206032:8499", 1), ("66:41:0303004:22", 1), ], f"после миграции осталось не по одной строке: {rows}" # И теперь повторная вставка действительно no-op. db.execute(text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"}) assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 2