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
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.
191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
"""Тесты 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)
|