gendesign/backend/tests/services/test_nspd_denorm.py
lekss361 db1ff5b1fc feat(nspd): denorm nspd_parcels + nspd_buildings tables (#94 PR3 of 4)
- data/sql/99_nspd_entities_denorm.sql: 2 tables (cad_num PK) + 6 indexes
  (quarter_cad, GIST geom, partial WHERE purpose ILIKE '%многокв%')
- nspd_denorm.py: denorm_parcel_feature / denorm_building_feature / denorm_dump
  - SAVEPOINT per row via with db.begin_nested()
  - ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326)
  - json.dumps(geom, ensure_ascii=False) for psycopg bind
  - All CAST(:x AS type) — no :type
- nspd_sync.py: inline denorm after _upsert_dump in harvest_quarter (non-fatal)
- nspd_denorm_backfill.py: Celery one-shot task (per-quarter commit)
- admin_etl.py: POST /api/v1/admin/etl/nspd-denorm-backfill (AdminTokenAuth)
- 19 tests: coerce helpers, denorm parcel/building, aggregate counts

Foundation for downstream features (МКД neighbors lookup, parcel attrs).

Part of #94
2026-05-16 20:20:03 +03:00

322 lines
11 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.

"""Тесты для nspd_denorm.py — coerce helpers + denorm_parcel/building/dump.
Не требует реального PostgreSQL — мокает db.execute() и db.begin_nested().
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
import pytest
from app.services.scrapers.nspd_denorm import (
_coerce_int,
_coerce_numeric,
denorm_building_feature,
denorm_dump,
denorm_parcel_feature,
)
# ── _coerce_int ────────────────────────────────────────────────────────────────
def test_coerce_int_str_number() -> None:
assert _coerce_int("5") == 5
def test_coerce_int_int_passthrough() -> None:
assert _coerce_int(17) == 17
def test_coerce_int_none_returns_none() -> None:
assert _coerce_int(None) is None
def test_coerce_int_invalid_returns_none() -> None:
assert _coerce_int("не_число") is None
def test_coerce_int_float_truncates() -> None:
assert _coerce_int(3.9) == 3
# ── _coerce_numeric ────────────────────────────────────────────────────────────
def test_coerce_numeric_float_passthrough() -> None:
assert _coerce_numeric(12.5) == 12.5
def test_coerce_numeric_str_dot() -> None:
assert _coerce_numeric("1234567.89") == pytest.approx(1234567.89)
def test_coerce_numeric_comma_decimal() -> None:
"""Европейский формат с запятой → корректный float."""
assert _coerce_numeric("1234567,89") == pytest.approx(1234567.89)
def test_coerce_numeric_none_returns_none() -> None:
assert _coerce_numeric(None) is None
def test_coerce_numeric_invalid_returns_none() -> None:
assert _coerce_numeric("N/A") is None
# ── Helpers для моков ──────────────────────────────────────────────────────────
def _make_mock_session() -> MagicMock:
"""Мок Session: begin_nested() — context manager, execute() → MagicMock."""
db = MagicMock()
# begin_nested() должен быть context manager
cm = MagicMock()
cm.__enter__ = MagicMock(return_value=None)
cm.__exit__ = MagicMock(return_value=False)
db.begin_nested.return_value = cm
return db
def _parcel_feature(
cad_num: str = "66:41:0204016:10",
area: Any = "500.0",
cost_value: Any = "1500000",
**extra_props: Any,
) -> dict[str, Any]:
props: dict[str, Any] = {
"cad_num": cad_num,
"area": area,
"cost_value": cost_value,
"permitted_use": "Для ИЖС",
"land_category": "Земли населённых пунктов",
"address": "г. Екатеринбург",
**extra_props,
}
return {
"layer": "parcels",
"feature_id": "123",
"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]},
"properties": props,
}
def _building_feature(
cad_num: str = "66:41:0204016:10:1",
purpose: str = "Многоквартирный дом",
**extra_props: Any,
) -> dict[str, Any]:
props: dict[str, Any] = {
"cad_num": cad_num,
"purpose": purpose,
"floors_above_ground": 9,
"floors_underground": 1,
"year_built": 1985,
"cost_value": "50000000",
"build_record_area": "3200.0",
"address": "ул. Ленина 1",
**extra_props,
}
return {
"layer": "buildings",
"feature_id": "456",
"geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]},
"properties": props,
}
# ── denorm_parcel_feature ──────────────────────────────────────────────────────
def test_denorm_parcel_feature_inserts() -> None:
"""Полный feature с cad_num → вызывает db.execute с правильными параметрами."""
db = _make_mock_session()
result = denorm_parcel_feature(
db,
feature=_parcel_feature(),
quarter_cad="66:41:0204016",
snapshot_date="2026-05-16",
)
assert result is True
db.begin_nested.assert_called_once()
db.execute.assert_called_once()
# Проверяем что параметры содержат нужные ключи
call_kwargs = db.execute.call_args[0][1] # positional dict
assert call_kwargs["cad_num"] == "66:41:0204016:10"
assert call_kwargs["quarter_cad"] == "66:41:0204016"
assert call_kwargs["permitted_use"] == "Для ИЖС"
assert call_kwargs["land_category"] == "Земли населённых пунктов"
assert call_kwargs["area_sqm"] == 500.0
assert call_kwargs["cost_value"] == 1500000.0
assert call_kwargs["cost_per_m2"] == pytest.approx(3000.0)
assert call_kwargs["snapshot_date"] == "2026-05-16"
def test_denorm_parcel_no_cad_num_skipped() -> None:
"""Feature без cad_num → возвращает False, db не вызывается."""
db = _make_mock_session()
feature: dict[str, Any] = {
"layer": "parcels",
"feature_id": None,
"geometry": None,
"properties": {"area": "100"},
}
result = denorm_parcel_feature(
db, feature=feature, quarter_cad="66:41:0204016", snapshot_date="2026-05-16"
)
assert result is False
db.execute.assert_not_called()
def test_denorm_parcel_zero_area_cost_per_m2_none() -> None:
"""area=0 → cost_per_m2 = None (нет деления на ноль)."""
db = _make_mock_session()
result = denorm_parcel_feature(
db,
feature=_parcel_feature(area="0", cost_value="1000"),
quarter_cad="66:41:0204016",
snapshot_date="2026-05-16",
)
assert result is True
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["cost_per_m2"] is None
def test_denorm_parcel_null_geometry() -> None:
"""feature без geometry → geom_json=None, INSERT всё равно вызывается."""
db = _make_mock_session()
feature = _parcel_feature()
feature["geometry"] = None
result = denorm_parcel_feature(
db, feature=feature, quarter_cad="66:41:0204016", snapshot_date="2026-05-16"
)
assert result is True
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["geom_json"] is None
def test_denorm_parcel_db_exception_returns_false() -> None:
"""DB execute raises → возвращает False (не propagate)."""
db = _make_mock_session()
db.execute.side_effect = Exception("DB constraint violation")
result = denorm_parcel_feature(
db,
feature=_parcel_feature(),
quarter_cad="66:41:0204016",
snapshot_date="2026-05-16",
)
assert result is False
# ── denorm_building_feature ────────────────────────────────────────────────────
def test_denorm_building_feature_inserts() -> None:
"""Полный building feature → INSERT с правильными параметрами."""
db = _make_mock_session()
result = denorm_building_feature(
db,
feature=_building_feature(),
quarter_cad="66:41:0204016",
snapshot_date="2026-05-16",
)
assert result is True
db.execute.assert_called_once()
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["cad_num"] == "66:41:0204016:10:1"
assert call_kwargs["purpose"] == "Многоквартирный дом"
assert call_kwargs["floors"] == 9
assert call_kwargs["floors_underground"] == 1
assert call_kwargs["year_built"] == 1985
assert call_kwargs["cost_value"] == 50000000.0
assert call_kwargs["build_record_area"] == 3200.0
def test_denorm_building_no_cad_num_skipped() -> None:
"""Building feature без cad_num → False."""
db = _make_mock_session()
feature: dict[str, Any] = {
"layer": "buildings",
"feature_id": None,
"geometry": None,
"properties": {"purpose": "Нежилое здание"},
}
result = denorm_building_feature(
db, feature=feature, quarter_cad="66:41:0204016", snapshot_date="2026-05-16"
)
assert result is False
db.execute.assert_not_called()
def test_denorm_building_str_floors_coerced() -> None:
"""floors_above_ground строкой → корректно парсится в int."""
db = _make_mock_session()
result = denorm_building_feature(
db,
feature=_building_feature(floors_above_ground="12"),
quarter_cad="66:41:0204016",
snapshot_date="2026-05-16",
)
assert result is True
call_kwargs = db.execute.call_args[0][1]
assert call_kwargs["floors"] == 12
# ── denorm_dump ────────────────────────────────────────────────────────────────
def test_denorm_dump_aggregates() -> None:
"""3 parcels + 2 buildings + 1 unknown layer → правильные счётчики."""
db = _make_mock_session()
features: list[dict[str, Any]] = [
_parcel_feature("66:41:0101001:1"),
_parcel_feature("66:41:0101001:2"),
_parcel_feature("66:41:0101001:3"),
_building_feature("66:41:0101001:1:1"),
_building_feature("66:41:0101001:1:2"),
{
"layer": "territorial_zones",
"feature_id": "tz-1",
"geometry": None,
"properties": {"type_zone": "Ж-1"},
},
]
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=features)
assert counts["parcels"] == 3
assert counts["buildings"] == 2
# territorial_zones не считается как ошибка — просто пропускается
assert counts["errors"] == 0
db.commit.assert_called_once()
def test_denorm_dump_empty_features() -> None:
"""Пустой список features → нули, commit всё равно вызывается."""
db = _make_mock_session()
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
assert counts == {"parcels": 0, "buildings": 0, "errors": 0}
db.commit.assert_called_once()
def test_denorm_dump_no_cad_num_counted_as_error() -> None:
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
db = _make_mock_session()
feature: dict[str, Any] = {
"layer": "parcels",
"feature_id": None,
"geometry": None,
"properties": {},
}
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
assert counts["parcels"] == 0
assert counts["errors"] == 1