gendesign/backend/tests/services/site_finder/test_permits_nearby.py
bot-backend 79eb49e9bc
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m18s
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 15m4s
feat(gisogd66): permits_nearby — точный радиус-запрос РНС/РВЭ рядом с участком (#2367)
Phase 3 наполнила gisogd_permits.geom -> заменяем 5-летний TODO-прокси
quarter-prefix точным ST_DWithin(500м) как НОВЫЙ ключ payload
permits_nearby. Старый ekburg quarter-prefix путь
(recent_permits_in_quarter/permits_summary) не тронут — работает
параллельно.

Честность счётчиков: total_count/rs_count/rv_count считаются по ПОЛНОЙ
выборке радиуса (без LIMIT в SQL), items капается срезом в Python (30),
items_truncated сигнализирует капнутый список — без тихих капов.

Аддитивно, non-fatal (сбой -> честный ноль), + блок в §6 PDF-отчёта
(html.escape на все строки), + AnalyzeResponse schema + regen api-types.ts.
2026-07-04 14:43:39 +05:00

191 lines
7.9 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.

"""Тесты 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)