gendesign/backend/tests/workers/test_nspd_geo.py
Light1YT 5f60668642
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 1m8s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m35s
Deploy / build-worker (push) Successful in 2m19s
fix(nspd-geo): coerce geom to MultiPolygon + wide cad_buildings schema in on-demand ingest
REAL code-level cause (prod-confirmed, 2 parcels: "Geometry type (Polygon) does
not match column type (MultiPolygon)") of single-contour parcels stuck "fetching"
— complements migrations 129/130 (which fixed the cad_parcels NOT NULL chain).

F1: _save_parcel/_save_quarter now ST_Multi() the geom so single-contour Polygons
fit the MultiPolygon columns (migr 93/58), mirroring the bulk_harvest paths.
F2: _save_building rewritten to the wide cad_buildings schema (migr 92):
quarter_cad_number (was quarter_cad_num → UndefinedColumn), _safe_int(floors)
(range strings → NULL), source='nspd' NOT NULL.
F3: upsert_features wraps each feature in begin_nested() SAVEPOINT so one bad
feature no longer aborts the whole quarter snapshot (backend.md rule; mirrors
grid-walk). New tests/workers/test_nspd_geo.py + savepoint tests. 89 passed.
2026-06-04 13:11:47 +05:00

294 lines
12 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.

"""Тесты для backend/app/workers/tasks/nspd_geo.py — on-demand НСПД-ингест.
Покрывает 3 prod-confirmed бага «пользователь вводит новый/произвольный реальный участок»:
- Finding 1: _save_parcel / _save_quarter должны ST_Multi() geom (колонки MultiPolygon,
migration 93 / 58) — иначе "Geometry type (Polygon) does not match column type (MultiPolygon)".
- Finding 2: _save_building пишет в широкую схему cad_buildings (migration 92):
quarter_cad_number (не quarter_cad_num), source NOT NULL, floors через _safe_int.
Mock DB через unittest.mock — без реального PostgreSQL / NSPD сети.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from app.workers.tasks.nspd_geo import _save_building, _save_parcel, _save_quarter
# ── Фикстуры payload (форма ответа _persist_target: {"data": {"features": [...]}}) ──
def _parcel_payload(cad_num: str = "66:41:0610029:83") -> dict[str, Any]:
"""NSPD-ответ для ЗУ (одноконтурный Polygon — триггер бага type-mismatch)."""
return {
"data": {
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[60.6, 56.8],
[60.61, 56.8],
[60.61, 56.81],
[60.6, 56.81],
[60.6, 56.8],
]
],
},
"properties": {"options": {"cad_num": cad_num}},
}
]
}
}
def _quarter_payload(cad_num: str = "66:41:0610029") -> dict[str, Any]:
"""NSPD-ответ для квартала (одноконтурный Polygon в EPSG:3857)."""
return {
"data": {
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6090000.0, 7590000.0],
[6090200.0, 7590000.0],
[6090200.0, 7590200.0],
[6090000.0, 7590200.0],
[6090000.0, 7590000.0],
]
],
},
"properties": {"options": {}},
}
]
}
}
def _building_payload(
cad_num: str = "66:41:0610029:83",
floors: Any = "5-7",
quarter: str | None = "66:41:0610029",
) -> dict[str, Any]:
"""NSPD-ответ для здания. floors по умолчанию range-строка "5-7" (триггер бага floors INT)."""
opts: dict[str, Any] = {
"cad_num": cad_num,
"purpose": "Жилое",
"building_name": "Тестовое здание",
"readable_address": "г Екатеринбург, ул Тестовая, д 1",
"area": "1234.56",
"floors": floors,
}
if quarter is not None:
opts["quarter_cad_number"] = quarter
return {
"data": {
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[6090000.0, 7590000.0],
[6090050.0, 7590000.0],
[6090050.0, 7590050.0],
[6090000.0, 7590050.0],
[6090000.0, 7590000.0],
]
],
},
"properties": {"options": opts},
}
]
}
}
def _capturing_db() -> tuple[MagicMock, list[tuple[str, Any]]]:
"""Mock Session, фиксирующий (sql_text, params) каждого db.execute."""
captured: list[tuple[str, Any]] = []
db = MagicMock()
db.execute = lambda stmt, params=None: captured.append((str(stmt), params)) or MagicMock()
return db, captured
def _strip_sql_comments(sql: str) -> str:
"""Убрать `-- ...` комментарии — чтобы assert'ить ИСПОЛНЯЕМЫЙ SQL, не текст комментов.
Комментарии легитимно упоминают ST_Transform/ST_Multi, поэтому проверяем выражения
только в реальном SQL.
"""
lines = []
for line in sql.splitlines():
idx = line.find("--")
lines.append(line if idx == -1 else line[:idx])
return "\n".join(lines)
# ── Finding 1: ST_Multi для MultiPolygon-колонок ─────────────────────────────
def test_save_parcel_sql_wraps_geom_in_st_multi() -> None:
"""cad_parcels.geom = MultiPolygon (migration 93) → SQL должен ST_Multi() обернуть geom.
Иначе одноконтурный Polygon из rosreestr2coord → "Geometry type (Polygon) does not
match column type (MultiPolygon)" → on-demand ингест участка крашится.
"""
db, captured = _capturing_db()
n = _save_parcel(db, _parcel_payload(), "66:41:0610029:83")
assert n == 1
assert len(captured) == 1
sql, _params = captured[0]
assert "ST_Multi(" in sql, "SQL _save_parcel должен содержать ST_Multi для MultiPolygon schema"
def test_save_parcel_sql_no_transform_parcel_is_4326() -> None:
"""parcel GeoJSON уже WGS84 (rosreestr2coord) — только ST_SetSRID(...,4326), без ST_Transform.
ST_Transform 3857→4326 на 4326-геометрии схлопнул бы участок в точку у (0,0).
"""
db, captured = _capturing_db()
_save_parcel(db, _parcel_payload(), "66:41:0610029:83")
sql, _params = captured[0]
executable = _strip_sql_comments(sql)
assert "ST_Transform" not in executable, "parcel уже 4326 — ST_Transform схлопнет в точку"
assert "ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)" in executable
def test_save_quarter_sql_wraps_geom_in_st_multi() -> None:
"""cad_quarters_geom.geom = MultiPolygon NOT NULL (migration 58) → ST_Multi() обязателен."""
db, captured = _capturing_db()
n = _save_quarter(db, _quarter_payload(), "66:41:0610029")
assert n == 1
assert len(captured) == 1
sql, _params = captured[0]
assert "ST_Multi(" in sql, "SQL _save_quarter должен содержать ST_Multi для MultiPolygon schema"
def test_save_quarter_sql_keeps_3857_to_4326_transform() -> None:
"""quarter GeoJSON в Mercator (3857) → ST_Transform к 4326 сохраняется (mirror bulk path)."""
db, captured = _capturing_db()
_save_quarter(db, _quarter_payload(), "66:41:0610029")
sql, _params = captured[0]
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql
def test_save_parcel_empty_features_returns_zero() -> None:
"""Пустой features → 0, без db.execute."""
db, captured = _capturing_db()
assert _save_parcel(db, {"data": {"features": []}}, "66:41:0610029:83") == 0
assert captured == []
# ── Finding 2: _save_building → широкая схема cad_buildings (migration 92) ─────
def test_save_building_uses_quarter_cad_number_column() -> None:
"""migration 92: колонка quarter_cad_number (НЕ quarter_cad_num) → иначе UndefinedColumn."""
db, captured = _capturing_db()
n = _save_building(db, _building_payload(), "66:41:0610029:83")
assert n == 1
sql, params = captured[0]
assert "quarter_cad_number" in sql
# старое имя колонки не должно встречаться (как отдельное слово quarter_cad_num)
assert "quarter_cad_num " not in sql
assert "quarter_cad_num," not in sql
assert "quarter_cad_num=" not in sql
assert params["qcad"] == "66:41:0610029"
def test_save_building_sets_source_not_null() -> None:
"""cad_buildings.source NOT NULL без дефолта (migration 92) → INSERT должен биндить source."""
db, captured = _capturing_db()
_save_building(db, _building_payload(), "66:41:0610029:83")
sql, params = captured[0]
assert ":source" in sql
assert params["source"] == "nspd"
def test_save_building_safe_int_floors_range_string() -> None:
"""floors INT: NSPD range "5-7" → _safe_int → None (иначе InvalidTextRepresentation).
Сырой "5-7" в floors INT крашил бы INSERT.
"""
db, captured = _capturing_db()
_save_building(db, _building_payload(floors="5-7"), "66:41:0610029:83")
_sql, params = captured[0]
assert params["floors"] is None # "5-7" не парсится → None, не сырая строка
def test_save_building_safe_int_floors_numeric() -> None:
"""floors "9" → int 9 (валидное число парсится)."""
db, captured = _capturing_db()
_save_building(db, _building_payload(floors="9"), "66:41:0610029:83")
_sql, params = captured[0]
assert params["floors"] == 9
def test_save_building_derives_quarter_when_missing() -> None:
"""Если NSPD не дал quarter_cad_number → выводим из cad_num (первые 3 сегмента)."""
db, captured = _capturing_db()
_save_building(db, _building_payload(quarter=None), "66:41:0610029:83")
_sql, params = captured[0]
assert params["qcad"] == "66:41:0610029"
def test_save_building_geom_transform_no_st_multi() -> None:
"""cad_buildings.geom = GEOMETRY(Geometry, 4326) полиморфный → ST_Multi НЕ нужен,
transform 3857→4326 сохраняется (mirror upsert_building)."""
db, captured = _capturing_db()
_save_building(db, _building_payload(), "66:41:0610029:83")
sql, _params = captured[0]
assert "ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:g), 3857), 4326)" in sql
assert "ST_Multi(" not in sql # полиморфная колонка — coerce не нужен
def test_save_building_skips_feature_with_other_cad_num() -> None:
"""NSPD может вернуть здания всего квартала — берём только запрошенный cad_num."""
db, captured = _capturing_db()
payload = _building_payload(cad_num="66:41:0610029:99") # чужой cad_num
n = _save_building(db, payload, "66:41:0610029:83")
assert n == 0
assert captured == []
def test_save_building_area_safe_numeric() -> None:
"""area NUMERIC: строковое "1234.56" → float через _safe_numeric."""
db, captured = _capturing_db()
_save_building(db, _building_payload(), "66:41:0610029:83")
_sql, params = captured[0]
assert params["area"] == 1234.56
# ── psycopg v3 CAST-trap guard (backend.md) ─────────────────────────────────
def test_save_sql_uses_cast_not_double_colon() -> None:
"""SQL _save_* использует CAST(:x AS jsonb), не :x::jsonb (SQLAlchemy psycopg v3 trap)."""
db, captured = _capturing_db()
_save_parcel(db, _parcel_payload(), "66:41:0610029:83")
_save_quarter(db, _quarter_payload(), "66:41:0610029")
_save_building(db, _building_payload(), "66:41:0610029:83")
for sql, _params in captured:
assert "::jsonb" not in sql, f"SQL содержит ::jsonb — нужен CAST(:x AS jsonb): {sql[:80]}"
assert "CAST(:props AS jsonb)" in sql