gendesign/backend/tests/services/site_finder/test_permits_nearby.py
bot-backend 7d5ca247ca fix(ptica): ключ gisogd_permits — id документа на портале, а не (группа, номер) (#2986)
`UNIQUE (doc_group, doc_num)` вводился, чтобы склеивать ОДИН документ,
пришедший из двух схем портала. Замер 20.08.2026 показал, что задача,
ради которой ключ введён, почти отсутствует, а побочный эффект огромен:
docNum у ГИСОГД НЕ уникален — разрешение и изменения к нему носят один
номер.

    группа   документов   различных key   различных docNum   схлопывается
    DocRS         6098          6096            4305             1793
    DocRV         5419          5415            4969              450
    DocIZ          548           547             393              155

    общих docNum между схемами (DocRS): 2   ← ради этого ключ и вводился
    общих key    между схемами (DocRS): 2   ← те же два

На проде 9182 строки против 12 065 документов на портале — нет 23.9 %
реестра. Пример 66-06-06-2026: портал отдаёт два документа (key …719586 —
само разрешение, key …752293 — изменения к нему), а UPSERT с
предпочтением позднего date_reg оставлял только изменение. Так вытеснено
598 из 4320 строк РНС (13.8 %) — в §6 на месте разрешения показывается
изменение к нему, без признака подмены.

Ключ стал `UNIQUE (source_key)`: разделяет разрешение и изменения (разные
key) и по-прежнему склеивает настоящие межсхемные дубли (у них key
ОБЩИЙ — ровно 7 записей по всем группам). Дедуп перед сменой не нужен:
source_key на проде уже уникален (9182 из 9182, NOT NULL).

Заодно группа DocIZ добавлена в GROUP_CODE — её не было вовсе, 548
документов не грузились. CHECK расширен значением 'IZ'.

§6 сужена до РНС/РВЭ ЯВНО: агрегат обещает total_count = rs_count +
rv_count, а строки 'IZ' попадали бы в total и ни в один счётчик.
Показывать ли изменения отдельной строкой — вопрос продуктовый (#2986);
до его решения сужение стоит в запросе, а не держится на том, что таких
строк «пока нет».

Проверки:
- два гейта на лоадер (GROUP_CODE и цель ON CONFLICT) — БЕЗ базы,
  двусторонние: на origin/main дают конкретные неверные значения
  ({'DocRS','DocRV'} и старый ON CONFLICT в тексте запроса);
- гейт на §6 и контроль инварианта total = rs + rv на данных — красные
  на origin/main;
- герметичная репетиция миграции на временной копии: со старым ключом
  разрешение и изменение схлопываются в одну строку (и остаётся именно
  изменение — как на проде), после миграции живут раздельно; межсхемный
  дубль по-прежнему склеивается; CHECK принимает 'IZ' и отвергает мусор.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:41:27 +05:00

230 lines
10 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.

"""Тесты permits_nearby (#105 Phase 3 — точный радиус-запрос РНС/РВЭ по geom, ГИСОГД-66).
Живую БД не трогаем — MagicMock-сессия (зеркало test_supply_layers / test_market_metrics):
`db.execute().mappings().all()` отдаёт заранее заданные строки. Проверяем:
• честный ноль на пустом радиусе (не ошибка, не None),
• разделение rs_count/rv_count по doc_group,
• округление distance_m + nearest_distance_m,
• ISO-сериализацию date_doc,
• ЧЕСТНЫЕ счётчики по полной выборке БЕЗ тихого капа (SQL без LIMIT), items капается
отдельно до 30 → items_truncated,
• отсутствие :name::type в SQL, порядок ORDER BY distance_m ASC,
• non-fatal семантику (сбой db.execute не глотается молча — пробрасывается вызывающему).
"""
from __future__ import annotations
import datetime as dt
import re
from typing import Any
from unittest.mock import MagicMock
import pytest
from app.services.site_finder.permits_nearby import (
_PERMITS_NEARBY_SQL,
get_permits_nearby,
)
_WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))"
def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
"""Сессия с одним execute → .mappings().all() == rows."""
db = MagicMock()
result = MagicMock()
result.mappings.return_value.all.return_value = rows
db.execute.return_value = result
return db
def _row(
doc_group: str,
distance_m: float,
*,
doc_name: str = "Разрешение",
doc_num: str = "66-01",
date_doc: dt.date | None = None,
approved_organization: str = "Администрация ЕКБ",
date_reg: dt.date | None = None,
) -> dict[str, Any]:
return {
"doc_group": doc_group,
"doc_name": doc_name,
"doc_num": doc_num,
"date_doc": date_doc,
"date_reg": date_reg,
"approved_organization": approved_organization,
"distance_m": distance_m,
}
def test_empty_radius_is_honest_zero_not_none() -> None:
"""0 строк в радиусе → честный ноль (total_count=0, items=[]), НЕ None, НЕ ошибка."""
out = get_permits_nearby(_mock_db([]), _WKT)
assert out is not None
assert out["total_count"] == 0
assert out["rs_count"] == 0
assert out["rv_count"] == 0
assert out["nearest_distance_m"] is None
assert out["items"] == []
assert out["items_truncated"] is False
assert out["radius_m"] == 500
assert out["source"] == "gisogd66"
def test_rs_rv_counts_split_by_doc_group() -> None:
"""rs_count считает 'RS', rv_count — 'RV'; прочие группы не попадают ни в один."""
rows = [
_row("RS", 100.0),
_row("RS", 150.0),
_row("RV", 200.0),
_row("OTHER", 250.0),
]
out = get_permits_nearby(_mock_db(rows), _WKT)
assert out["total_count"] == 4
assert out["rs_count"] == 2
assert out["rv_count"] == 1
assert len(out["items"]) == 4
assert out["items_truncated"] is False
def test_distance_rounding_and_nearest() -> None:
"""distance_m округляется до 1 знака; nearest_distance_m = ближайший (rows отсортированы)."""
rows = [_row("RS", 123.456), _row("RV", 456.789)]
out = get_permits_nearby(_mock_db(rows), _WKT)
assert out["items"][0]["distance_m"] == 123.5
assert out["items"][1]["distance_m"] == 456.8
assert out["nearest_distance_m"] == 123.5
def test_date_doc_serialized_to_iso() -> None:
"""date_doc → ISO-строка; None → None (не падает)."""
rows = [
_row("RS", 100.0, date_doc=dt.date(2025, 3, 14)),
_row("RV", 200.0, date_doc=None),
]
out = get_permits_nearby(_mock_db(rows), _WKT)
assert out["items"][0]["date_doc"] == "2025-03-14"
assert out["items"][1]["date_doc"] is None
def test_item_shape_fields() -> None:
"""Каждый item несёт ровно контрактные ключи (не протекает date_reg и пр.)."""
rows = [_row("RS", 42.0, doc_name="РНС №5", approved_organization="Мингос")]
out = get_permits_nearby(_mock_db(rows), _WKT)
item = out["items"][0]
assert set(item.keys()) == {
"doc_group",
"doc_name",
"doc_num",
"date_doc",
"approved_organization",
"distance_m",
}
assert item["doc_name"] == "РНС №5"
assert item["approved_organization"] == "Мингос"
def test_radius_m_passed_through_to_params_and_result() -> None:
"""Кастомный radius_m прокидывается в bind-params И в выходной agg."""
db = _mock_db([])
out = get_permits_nearby(db, _WKT, radius_m=1000)
assert out["radius_m"] == 1000
_args, _kwargs = db.execute.call_args
params = _args[1]
assert params["radius_m"] == 1000
assert params["wkt"] == _WKT
def test_sql_no_limit_counts_over_full_selection() -> None:
"""SQL БЕЗ LIMIT (честные счётчики по всей выборке) + ORDER BY / ST_DWithin.
Кап 30 делается в Python на items, НЕ в SQL — иначе total_count/rs_count/rv_count
занижались бы (тихий кап, запрещён проектной конвенцией).
"""
sql = str(_PERMITS_NEARBY_SQL).lower()
assert "gisogd_permits" in sql
assert "st_dwithin" in sql
assert "order by distance_m asc" in sql
assert "limit" not in sql, "LIMIT в SQL капнул бы счётчики — кап только на items в Python"
# items_limit больше не bind-параметр (капаем rows[:30] в Python).
db = _mock_db([])
get_permits_nearby(db, _WKT)
_args, _kwargs = db.execute.call_args
assert "items_limit" not in _args[1]
def test_items_capped_but_counts_honest_over_full_selection() -> None:
"""35 строк в выборке → items len=30 (кап), но total_count=35 честный + truncated=True.
Ядро фикса «без тихих капов»: список отдаётся усечённым (progressive-disclosure на
фронте по items_truncated), а счётчики отражают РЕАЛЬНОЕ число разрешений в радиусе.
"""
# 20×RS ближайшие, затем 15×RV дальше — итого 35, отсортированы по distance ASC.
rows = [_row("RS", float(d)) for d in range(10, 30)] # 20 строк, 10..29 м
rows += [_row("RV", float(d)) for d in range(30, 45)] # 15 строк, 30..44 м
assert len(rows) == 35
out = get_permits_nearby(_mock_db(rows), _WKT)
assert out["total_count"] == 35
assert out["rs_count"] == 20
assert out["rv_count"] == 15
assert len(out["items"]) == 30
assert out["items_truncated"] is True
# nearest — по ближайшей строке (сохраняется даже при капе списка).
assert out["nearest_distance_m"] == 10.0
# items — именно первые 30 ближайших (последние 5 дальних RV не попали).
assert all(it["distance_m"] <= 39.0 for it in out["items"])
def test_sql_no_double_colon_cast() -> None:
"""psycopg v3 / backend.md: никаких :name::type (::geography приклеен к ')', OK)."""
sql = str(_PERMITS_NEARBY_SQL)
assert re.search(r":[a-z_]+::[a-z]", sql) is None, f"double-colon cast in SQL:\n{sql}"
def test_db_error_propagates_not_swallowed() -> None:
"""Сбой db.execute пробрасывается (non-fatal обёртка живёт в parcels.py, не тут)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("db down")
with pytest.raises(RuntimeError, match="db down"):
get_permits_nearby(db, _WKT)
def test_sql_excludes_the_amendments_group() -> None:
"""Выборка сужена до РНС/РВЭ явно — иначе total_count перестанет сходиться (#2986).
С #2986 в gisogd_permits появилась третья группа 'IZ' (изменения в разрешение).
Агрегат обещает `total_count = rs_count + rv_count`, а строки 'IZ' попадали бы в
total и не попадали ни в один счётчик. Показывать ли изменения в §6 — вопрос
продуктовый; до его решения сужение должно стоять в запросе явно, а не
держаться на том, что таких строк «пока нет».
"""
sql = str(_PERMITS_NEARBY_SQL)
assert (
"doc_group IN ('RS', 'RV')" in sql
), f"запрос не сужен по группе — строки 'IZ' сломают total_count:\n{sql}"
def test_total_equals_rs_plus_rv_even_if_iz_leaks_in() -> None:
"""Контроль инварианта на данных: даже если строка 'IZ' просочилась, счёт сходится.
Проверяем не текст запроса, а поведение агрегата: сумма частей равна целому.
Тест краснеет, если кто-то снимет сужение и не добавит счётчик для 'IZ'.
"""
rows = [
{
"doc_group": g,
"doc_name": f"Документ {g}",
"doc_num": f"{i}",
"date_doc": dt.date(2026, 2, 26),
"date_reg": dt.date(2026, 2, 26),
"approved_organization": "Администрация",
"distance_m": 10.0 + i,
}
for i, g in enumerate(("RS", "RV", "RS"))
]
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
res = get_permits_nearby(db, _WKT)
assert res["total_count"] == res["rs_count"] + res["rv_count"], res