gendesign/backend/tests/sql/test_2464_land_reservation_dedup.py
bot-backend 14818f0303
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864)
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после
#3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый
коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки
на нетронутых строках (длинные assert-сообщения и т.п.).

Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего,
кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих
проектах, повторный `format --check` — 1001 файл без изменений
(идемпотентно), compileall чистый.

Refs #2864
2026-08-21 16:39:28 +05:00

215 lines
9.5 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.

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