- #1 (BLOCK): ST_GeogFromGeoJSON → ST_Transform(ST_SetSRID(..., 3857), 4326)::geography so EPSG:3857 features from get_territorial_zones_in_bbox are correctly stored in GEOGRAPHY(POLYGON, 4326) column (matches cad_parcels/cad_buildings pattern) - #2: zone_id fallback changed from counter-based f"{quarter_cad}_{inserted}" to md5(json(props))[:12] — stable between runs, prevents wrong-row ON CONFLICT - #3: rename 101_cad_nspd_overlay_tables.sql → 102_* (101_user_custom_pois.sql exists) - #4: add comment clarifying SQLAlchemy autobegin guarantees outer tx before begin_nested() - Tests: updated test_feature_without_zone_id_uses_fallback to assert md5 format + idempotency
198 lines
7 KiB
Python
198 lines
7 KiB
Python
"""Тесты для _save_territorial_zones (bulk_harvest.py) — mock-based.
|
||
|
||
Проверяет:
|
||
- Успешный UPSERT 3 features → 3 строки вставлены
|
||
- Повторный вызов → ON CONFLICT обновляет, не дублирует
|
||
- Feature без geometry → строка вставлена с geom=NULL, без краша
|
||
- Feature без zone_id → синтетический fallback zone_id используется
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
from app.services.cadastre.bulk_harvest import _save_territorial_zones
|
||
|
||
|
||
def _make_feature(
|
||
feature_id: Any = "zone_1",
|
||
zone_code: str = "Ж-1",
|
||
zone_name: str = "Жилая смешанная",
|
||
permitted_use: str = "ИЖС",
|
||
has_geometry: bool = True,
|
||
) -> dict:
|
||
"""Создать raw feature dict в формате get_features_in_bbox_grid."""
|
||
geom = (
|
||
{
|
||
"type": "Polygon",
|
||
"coordinates": [
|
||
[
|
||
[6090000.0, 7590000.0],
|
||
[6090100.0, 7590000.0],
|
||
[6090100.0, 7590100.0],
|
||
[6090000.0, 7590100.0],
|
||
[6090000.0, 7590000.0],
|
||
]
|
||
],
|
||
}
|
||
if has_geometry
|
||
else None
|
||
)
|
||
return {
|
||
"id": feature_id,
|
||
"geometry": geom,
|
||
"properties": {
|
||
"zone_code": zone_code,
|
||
"zone_name": zone_name,
|
||
"permitted_use": permitted_use,
|
||
},
|
||
}
|
||
|
||
|
||
def _make_db_mock() -> MagicMock:
|
||
"""Mock SQLAlchemy Session с begin_nested() savepoint support."""
|
||
db = MagicMock()
|
||
# begin_nested() используется как context manager
|
||
savepoint_ctx = MagicMock()
|
||
savepoint_ctx.__enter__ = MagicMock(return_value=savepoint_ctx)
|
||
savepoint_ctx.__exit__ = MagicMock(return_value=False)
|
||
db.begin_nested.return_value = savepoint_ctx
|
||
return db
|
||
|
||
|
||
class TestSaveTerritorialZones:
|
||
"""Тесты для _save_territorial_zones."""
|
||
|
||
def test_three_features_inserted(self) -> None:
|
||
"""3 features → returned count == 3, execute вызван 3 раза."""
|
||
db = _make_db_mock()
|
||
features = [
|
||
_make_feature("z1", "Ж-1"),
|
||
_make_feature("z2", "ОД-1"),
|
||
_make_feature("z3", "П-1"),
|
||
]
|
||
|
||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||
|
||
assert result == 3
|
||
assert db.execute.call_count == 3
|
||
db.commit.assert_called_once()
|
||
|
||
def test_empty_features_list(self) -> None:
|
||
"""Пустой список → 0 inserted, commit всё равно вызван."""
|
||
db = _make_db_mock()
|
||
|
||
result = _save_territorial_zones(db, "66:41:0204016", [])
|
||
|
||
assert result == 0
|
||
db.execute.assert_not_called()
|
||
db.commit.assert_called_once()
|
||
|
||
def test_feature_without_geometry_no_crash(self) -> None:
|
||
"""Feature без geometry → geom=NULL, строка вставлена без краша."""
|
||
db = _make_db_mock()
|
||
features = [_make_feature("zone_no_geom", has_geometry=False)]
|
||
|
||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||
|
||
assert result == 1
|
||
# Проверяем что geom параметр передан как None
|
||
call_kwargs: dict = db.execute.call_args[0][1]
|
||
assert call_kwargs["geom"] is None
|
||
|
||
def test_feature_without_zone_id_uses_fallback(self) -> None:
|
||
"""Feature без id → md5-based fallback zone_id (stable между runs)."""
|
||
db = _make_db_mock()
|
||
features = [
|
||
{
|
||
"id": None,
|
||
"geometry": None,
|
||
"properties": {"zone_code": "Ж-2"},
|
||
}
|
||
]
|
||
|
||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||
|
||
assert result == 1
|
||
call_kwargs = db.execute.call_args[0][1]
|
||
zone_id: str = call_kwargs["zone_id"]
|
||
# fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars)
|
||
assert zone_id.startswith("66:41:0204016_")
|
||
suffix = zone_id.split("_", 3)[-1]
|
||
assert len(suffix) == 12
|
||
assert all(c in "0123456789abcdef" for c in suffix)
|
||
|
||
# Второй вызов с теми же данными → тот же zone_id (идемпотентность)
|
||
db2 = _make_db_mock()
|
||
_save_territorial_zones(db2, "66:41:0204016", features)
|
||
call_kwargs2 = db2.execute.call_args[0][1]
|
||
assert call_kwargs2["zone_id"] == zone_id
|
||
|
||
def test_zone_id_from_props_id(self) -> None:
|
||
"""Если feature.id=None, но props['id'] есть — используется props['id']."""
|
||
db = _make_db_mock()
|
||
features = [
|
||
{
|
||
"id": None,
|
||
"geometry": None,
|
||
"properties": {"id": "props_id_42", "zone_code": "Ж-3"},
|
||
}
|
||
]
|
||
|
||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||
|
||
assert result == 1
|
||
call_kwargs = db.execute.call_args[0][1]
|
||
assert call_kwargs["zone_id"] == "props_id_42"
|
||
|
||
def test_execute_error_logged_not_raised(self) -> None:
|
||
"""Exception в execute → строка не вставлена, warning залогирован, не re-raise."""
|
||
db = _make_db_mock()
|
||
db.execute.side_effect = RuntimeError("DB error")
|
||
features = [_make_feature("z_err")]
|
||
|
||
# Не должен бросить исключение
|
||
result = _save_territorial_zones(db, "66:41:0204016", features)
|
||
|
||
assert result == 0
|
||
db.commit.assert_called_once()
|
||
|
||
def test_savepoint_used_per_row(self) -> None:
|
||
"""begin_nested() вызывается для каждой строки (SAVEPOINT паттерн)."""
|
||
db = _make_db_mock()
|
||
features = [_make_feature(f"z{i}") for i in range(3)]
|
||
|
||
_save_territorial_zones(db, "66:41:0204016", features)
|
||
|
||
assert db.begin_nested.call_count == 3
|
||
|
||
def test_quarter_cad_param_passed(self) -> None:
|
||
"""quarter_cad правильно передаётся в SQL параметры."""
|
||
db = _make_db_mock()
|
||
features = [_make_feature("zone_check")]
|
||
|
||
_save_territorial_zones(db, "66:41:9999999", features)
|
||
|
||
call_kwargs = db.execute.call_args[0][1]
|
||
assert call_kwargs["quarter_cad"] == "66:41:9999999"
|
||
|
||
def test_raw_props_serialized(self) -> None:
|
||
"""raw_props — JSON строка из properties dict."""
|
||
import json
|
||
|
||
db = _make_db_mock()
|
||
features = [
|
||
{
|
||
"id": "z_props",
|
||
"geometry": None,
|
||
"properties": {"zone_code": "ОД-2", "extra": "value"},
|
||
}
|
||
]
|
||
|
||
_save_territorial_zones(db, "66:41:0204016", features)
|
||
|
||
call_kwargs = db.execute.call_args[0][1]
|
||
raw = json.loads(call_kwargs["raw_props"])
|
||
assert raw["zone_code"] == "ОД-2"
|
||
assert raw["extra"] == "value"
|