"""Миграция 191 чинит ровно 11 строк с датой Генплана и ничего кроме (#2464). До #2981 `_extract_act_date` брал первое «от DD.MM.YYYY» во всём OCR-тексте, и в `act_date` уезжала дата из «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении Генерального плана города»». #2981 чинит извлечение **только вперёд**: UPSERT загрузчика заканчивается `ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому пятничный прогон существующие строки не перезапишет. Отсюда разовая миграция. Замер прода 20.08.2026: 11 строк с `act_date='2004-07-06'` — 9 по документу 89adb28a… (развязка) и 2 по 9b9d9a99… (Энергостроителей). Верные даты взяты из самих PDF, распознанных тем же трактом, что использует загрузчик. Тест герметичный: временная копия таблицы, боевые данные не читаются и не меняются. Проверяется тело миграции целиком, а не переписанный от руки UPDATE. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import re from datetime import date 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" / "191_land_reservation_act_date_backfill.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}") _URL_РАЗВЯЗКА = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8" _URL_ЭНЕРГО = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5" _URL_ЧУЖОЙ = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/ffffffffffffffffffffffffffffffff" _TEMP = """ CREATE TEMP TABLE land_reservation ( id bigserial PRIMARY KEY, cad_num text NOT NULL, act_date date, doc_url text ) ON COMMIT DROP """ # Прод-форма: 9 строк развязки + 2 Энергостроителей + контрольные посторонние. _SEED = ( [(f"66:41:0603006:{i}", date(2004, 7, 6), _URL_РАЗВЯЗКА) for i in range(9)] + [(f"66:41:0303004:{i}", date(2004, 7, 6), _URL_ЭНЕРГО) for i in range(2)] + [ # Чужой документ с той же датой — трогать НЕЛЬЗЯ: 06.07.2004 может быть # и настоящей датой акта, сужение по doc_url именно для этого. ("66:41:0000001:1", date(2004, 7, 6), _URL_ЧУЖОЙ), # Верные строки других документов — не должны сдвинуться. ("66:41:0502080:22", date(2015, 9, 29), _URL_ЧУЖОЙ), ] ) def _body() -> list[str]: """Тело миграции без комментариев, разбитое на выполнимые куски.""" raw = _MIGRATION.read_text(encoding="utf-8") без_комментов = "\n".join(ln for ln in raw.splitlines() if not ln.lstrip().startswith("--")) return [ chunk.strip() for chunk in re.split(r";\s*(?:\n|$)", без_комментов) if chunk.strip() and chunk.strip().upper() not in {"BEGIN", "COMMIT"} ] def _run() -> dict[tuple[str, date], int]: session = sessionmaker(bind=create_engine(_dsn()))() try: session.execute(text(_TEMP)) for cad, d, url in _SEED: session.execute( text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"), {"c": cad, "d": d, "u": url}, ) for chunk in _body(): session.execute(text(chunk)) rows = session.execute( text( "SELECT right(doc_url,32) AS h, act_date, count(*)" " FROM land_reservation GROUP BY 1,2" ) ).all() return {(str(r[0]), r[1]): int(r[2]) for r in rows} finally: session.rollback() session.close() def test_genplan_dates_are_replaced_with_real_ones() -> None: """Головной: 9 + 2 строки получают дату своего постановления.""" res = _run() assert res.get(("89adb28a3677e7df933e2d9ce0f205c8", date(2022, 5, 27))) == 9, res assert res.get(("9b9d9a998f578db56315bb816fc2ebf5", date(2020, 2, 12))) == 2, res def test_no_2004_rows_left_in_target_documents() -> None: """Контроль полноты: в этих двух документах даты 2004 года не остаётся.""" res = _run() for h in ("89adb28a3677e7df933e2d9ce0f205c8", "9b9d9a998f578db56315bb816fc2ebf5"): assert res.get((h, date(2004, 7, 6))) is None, f"остались строки 2004 у {h}: {res}" def test_other_documents_with_same_date_are_untouched() -> None: """Контроль сужения: та же дата в ЧУЖОМ документе не трогается. Без условия по doc_url миграция переписала бы любую строку с 06.07.2004, включая те, где эта дата настоящая. """ res = _run() assert res.get(("ffffffffffffffffffffffffffffffff", date(2004, 7, 6))) == 1, res def test_correct_rows_do_not_move() -> None: """Контроль от переусердствования: верные даты остаются на месте.""" res = _run() assert res.get(("ffffffffffffffffffffffffffffffff", date(2015, 9, 29))) == 1, res def test_migration_is_idempotent() -> None: """Повторный запуск ничего не меняет — условие act_date='2004-07-06' не выполнится.""" session = sessionmaker(bind=create_engine(_dsn()))() try: session.execute(text(_TEMP)) for cad, d, url in _SEED: session.execute( text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"), {"c": cad, "d": d, "u": url}, ) for _ in range(2): for chunk in _body(): session.execute(text(chunk)) n = session.execute( text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2022-05-27'") ).scalar() assert n == 9, f"после двух прогонов строк с 2022-05-27: {n}" finally: session.rollback() session.close() def test_without_the_migration_rows_stay_wrong() -> None: """Контроль-двойник: БЕЗ прогона миграции строки остаются с датой Генплана. Обязателен: без него тест неотличим от «оно и так было правильно». """ session = sessionmaker(bind=create_engine(_dsn()))() try: session.execute(text(_TEMP)) for cad, d, url in _SEED: session.execute( text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"), {"c": cad, "d": d, "u": url}, ) # Миграцию НЕ прогоняем. n = session.execute( text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2004-07-06'") ).scalar() assert n == 12, f"засеяно не то состояние — строк с датой Генплана: {n}" assert ( session.execute( text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2022-05-27'") ).scalar() == 0 ), "верная дата появилась без миграции — тест смотрел бы не туда" finally: session.rollback() session.close()