All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m33s
CI / backend-tests (pull_request) Successful in 6m33s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 5m24s
Deploy / deploy (push) Successful in 1m30s
Сервис get_developer_attribution поверх fn_developer_for_parcel (миграция 149): топ-1 застройщик участка + track-record (РНС/РВЭ + домрф) + nearby_developers. Дедуп по норм-ИНН (лучший match_method первым). Wire в analyze additive без флага (чистый DB-резолвер по индексам, graceful → None). Beat-refresh developer_registry ежемесячно 05:30 МСК после ekburg-permits. Pydantic-схемы + 18 тестов. Источники: ekburg_construction_permits (РНС, ИНН) ⋈ domrf_kn_objects по нормализованному ИНН + spatial/quarter fallback. 68% domrf-застройщиков имеют РНС. EXPLAIN: point-lookup 0.06ms, резолвер ~30ms (functional/GIST индексы в миграции). Closes #1088
291 lines
13 KiB
Python
291 lines
13 KiB
Python
"""Unit-тесты атрибуции застройщика участка (#1088 «GG-форсайт»).
|
||
|
||
Тестируем get_developer_attribution с MagicMock-сессией (без живой БД, без app.main):
|
||
• happy-path: rows резолвера → primary (топ-1) + nearby_developers;
|
||
• дедуп по developer_inn: один застройщик в exact+spatial+quarter → один в выводе,
|
||
сохраняется ЛУЧШИЙ (первый по ORDER BY резолвера) match_method;
|
||
• nearby содержит только spatial_150m/quarter (не exact, не дубль primary);
|
||
• пустой результат резолвера → None;
|
||
• graceful-degrade при БД-ошибке (OperationalError/ProgrammingError/DataError —
|
||
incl. ещё не задеплоенная миграция 149) → None, analyze не падает;
|
||
• SQL использует CAST(:x AS type), НИКОГДА :x::type (psycopg v3).
|
||
|
||
Детерминированно, без LLM. rows мокаются как dict (RowMapping-совместимо через
|
||
__getitem__, как реальный .mappings().all()).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
|
||
|
||
from app.services.site_finder.developer_attribution import (
|
||
_DEVELOPER_FOR_PARCEL_SQL,
|
||
get_developer_attribution,
|
||
)
|
||
|
||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
|
||
"""Сессия с одним execute → .mappings().all() == rows (зеркало test_future_supply)."""
|
||
db = MagicMock()
|
||
result = MagicMock()
|
||
result.mappings.return_value.all.return_value = rows
|
||
db.execute.return_value = result
|
||
return db
|
||
|
||
|
||
def _row(
|
||
inn: str,
|
||
*,
|
||
name: str | None = "Канон Девелопмент",
|
||
source: str = "permits",
|
||
match_method: str = "exact_cadastral",
|
||
distance_m: float | None = 0.0,
|
||
project: str | None = "ЖК Тест",
|
||
status: str | None = "RNS",
|
||
obj_class: str | None = "Комфорт",
|
||
permits_total: int | None = 5,
|
||
domrf_objects_total: int | None = 3,
|
||
domrf_active_objects: int | None = 2,
|
||
in_both_sources: bool | None = True,
|
||
) -> dict[str, Any]:
|
||
"""Одна строка резолвера fn_developer_for_parcel (полный набор колонок)."""
|
||
return {
|
||
"developer_inn": inn,
|
||
"developer_name": name,
|
||
"source": source,
|
||
"match_method": match_method,
|
||
"distance_m": distance_m,
|
||
"project": project,
|
||
"status": status,
|
||
"obj_class": obj_class,
|
||
"permits_total": permits_total,
|
||
"domrf_objects_total": domrf_objects_total,
|
||
"domrf_active_objects": domrf_active_objects,
|
||
"in_both_sources": in_both_sources,
|
||
}
|
||
|
||
|
||
# ── SQL-конвенция (psycopg v3) ──────────────────────────────────────────────────
|
||
|
||
|
||
def test_sql_uses_cast_not_double_colon() -> None:
|
||
"""psycopg v3: запрещён :name::type (SQLAlchemy 2.0 молча роняет его → SyntaxError)."""
|
||
sql = str(_DEVELOPER_FOR_PARCEL_SQL)
|
||
assert "::" not in sql, f"double-colon cast найден в SQL: {sql!r}"
|
||
assert "CAST(:radius AS double precision)" in sql
|
||
|
||
|
||
# ── happy-path ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestPrimaryAttribution:
|
||
def test_single_developer_becomes_primary(self) -> None:
|
||
db = _mock_db([_row("6671138299", name="ЕКБ Девелопмент")])
|
||
result = get_developer_attribution(db, "66:41:0303161:123")
|
||
|
||
assert result is not None
|
||
assert result.primary.developer_inn == "6671138299"
|
||
assert result.primary.developer_name == "ЕКБ Девелопмент"
|
||
assert result.primary.match_method == "exact_cadastral"
|
||
assert result.primary.distance_m == 0.0
|
||
assert result.primary.permits_total == 5
|
||
assert result.primary.in_both_sources is True
|
||
assert result.nearby_developers == []
|
||
|
||
def test_radius_passed_to_resolver(self) -> None:
|
||
db = _mock_db([_row("6671138299")])
|
||
get_developer_attribution(db, "66:41:0303161:123", radius_m=250.0)
|
||
|
||
_args, _kwargs = db.execute.call_args
|
||
params = _args[1]
|
||
assert params == {"cad_num": "66:41:0303161:123", "radius": 250.0}
|
||
|
||
def test_default_radius_is_150(self) -> None:
|
||
db = _mock_db([_row("6671138299")])
|
||
get_developer_attribution(db, "66:41:0303161:123")
|
||
params = db.execute.call_args[0][1]
|
||
assert params["radius"] == 150.0
|
||
|
||
def test_first_row_is_primary_resolver_order_trusted(self) -> None:
|
||
# Резолвер уже ORDER BY method (exact > spatial > quarter): первая строка = топ-1.
|
||
rows = [
|
||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||
_row("2222222222", match_method="spatial_150m", distance_m=42.0),
|
||
]
|
||
result = get_developer_attribution(db := _mock_db(rows), "66:41:0303161:1")
|
||
assert db is not None
|
||
assert result is not None
|
||
assert result.primary.developer_inn == "1111111111"
|
||
assert result.primary.match_method == "exact_cadastral"
|
||
|
||
|
||
# ── дедуп по developer_inn ───────────────────────────────────────────────────
|
||
|
||
|
||
class TestDedup:
|
||
def test_same_inn_across_stages_dedups_keeping_best_method(self) -> None:
|
||
# Один застройщик встречается в exact + spatial + quarter (резолвер не
|
||
# взаимоисключающий). Дедуп оставляет ПЕРВЫЙ (лучший по ORDER BY) — exact.
|
||
rows = [
|
||
_row("6671138299", match_method="exact_cadastral", distance_m=0.0, project="ЖК A"),
|
||
_row("6671138299", match_method="spatial_150m", distance_m=80.0, project="ЖК A-2"),
|
||
_row("6671138299", match_method="quarter", distance_m=None, project="ЖК A-3"),
|
||
]
|
||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||
|
||
assert result is not None
|
||
assert result.primary.developer_inn == "6671138299"
|
||
# ЛУЧШИЙ метод сохранён (первый по ORDER BY), не перезаписан spatial/quarter.
|
||
assert result.primary.match_method == "exact_cadastral"
|
||
assert result.primary.project == "ЖК A"
|
||
# Тот же ИНН не попадает в nearby (дедупнут целиком).
|
||
assert result.nearby_developers == []
|
||
|
||
def test_distinct_developers_split_primary_and_nearby(self) -> None:
|
||
rows = [
|
||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||
_row("2222222222", match_method="spatial_150m", distance_m=55.0),
|
||
_row("3333333333", match_method="quarter", distance_m=None),
|
||
]
|
||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||
|
||
assert result is not None
|
||
assert result.primary.developer_inn == "1111111111"
|
||
nearby_inns = [d.developer_inn for d in result.nearby_developers]
|
||
assert nearby_inns == ["2222222222", "3333333333"]
|
||
|
||
def test_nearby_developer_dedups_too(self) -> None:
|
||
# Сосед, дважды встретившийся (spatial + quarter), в nearby один раз (лучший).
|
||
rows = [
|
||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||
_row("2222222222", match_method="spatial_150m", distance_m=55.0),
|
||
_row("2222222222", match_method="quarter", distance_m=None),
|
||
]
|
||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||
|
||
assert result is not None
|
||
assert [d.developer_inn for d in result.nearby_developers] == ["2222222222"]
|
||
assert result.nearby_developers[0].match_method == "spatial_150m"
|
||
|
||
|
||
class TestNearbyFiltering:
|
||
def test_nearby_excludes_secondary_exact_method(self) -> None:
|
||
# Второй (другой ИНН) с exact (multi-value РНС на тот же ЗУ) — не primary, но
|
||
# в nearby кладём только spatial/quarter-контекст («строят рядом/в квартале»).
|
||
rows = [
|
||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||
_row("2222222222", match_method="exact_cadastral", distance_m=0.0),
|
||
_row("3333333333", match_method="spatial_150m", distance_m=60.0),
|
||
]
|
||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||
|
||
assert result is not None
|
||
assert result.primary.developer_inn == "1111111111"
|
||
# Вторичный exact (2222) отфильтрован; остаётся только spatial-сосед (3333).
|
||
assert [d.developer_inn for d in result.nearby_developers] == ["3333333333"]
|
||
|
||
def test_nearby_includes_both_spatial_and_quarter(self) -> None:
|
||
rows = [
|
||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||
_row("2222222222", match_method="spatial_150m", distance_m=30.0),
|
||
_row("3333333333", match_method="quarter", distance_m=None, source="domrf"),
|
||
]
|
||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||
|
||
assert result is not None
|
||
methods = [d.match_method for d in result.nearby_developers]
|
||
assert methods == ["spatial_150m", "quarter"]
|
||
|
||
|
||
# ── nullable track-record (FULL OUTER JOIN → None-колонки) ───────────────────
|
||
|
||
|
||
class TestNullableFields:
|
||
def test_developer_known_only_to_one_source(self) -> None:
|
||
# Застройщик из domrf без РНС: permits_total может быть 0, distance_m None.
|
||
row = _row(
|
||
"7777777777",
|
||
source="domrf",
|
||
match_method="quarter",
|
||
distance_m=None,
|
||
status="Строящиеся",
|
||
permits_total=0,
|
||
in_both_sources=False,
|
||
)
|
||
result = get_developer_attribution(_mock_db([row]), "66:41:0303161:1")
|
||
|
||
assert result is not None
|
||
assert result.primary.distance_m is None
|
||
assert result.primary.permits_total == 0
|
||
assert result.primary.in_both_sources is False
|
||
|
||
def test_null_track_record_columns_pass_through(self) -> None:
|
||
row = _row(
|
||
"8888888888",
|
||
name=None,
|
||
domrf_objects_total=None,
|
||
domrf_active_objects=None,
|
||
in_both_sources=None,
|
||
)
|
||
result = get_developer_attribution(_mock_db([row]), "66:41:0303161:1")
|
||
|
||
assert result is not None
|
||
assert result.primary.developer_name is None
|
||
assert result.primary.domrf_objects_total is None
|
||
assert result.primary.in_both_sources is None
|
||
|
||
|
||
# ── пустой результат → None ──────────────────────────────────────────────────
|
||
|
||
|
||
def test_empty_resolver_result_returns_none() -> None:
|
||
result = get_developer_attribution(_mock_db([]), "66:41:0303161:1")
|
||
assert result is None
|
||
|
||
|
||
def test_all_rows_null_inn_returns_none() -> None:
|
||
# Резолвер гарантирует NOT NULL inn (WHERE c.inn IS NOT NULL), но защищаемся:
|
||
# все строки с NULL-ИНН → нечего атрибутировать → None.
|
||
rows = [_row("ignored")]
|
||
rows[0]["developer_inn"] = None
|
||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||
assert result is None
|
||
|
||
|
||
# ── graceful-degrade при БД-ошибке ───────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"exc",
|
||
[
|
||
OperationalError("stmt", {}, Exception("connection lost")),
|
||
ProgrammingError("stmt", {}, Exception("function fn_developer_for_parcel does not exist")),
|
||
DataError("stmt", {}, Exception("invalid input syntax")),
|
||
],
|
||
ids=["operational", "programming_pre_migration", "data"],
|
||
)
|
||
def test_db_error_degrades_to_none(exc: Exception) -> None:
|
||
# Миграция 149 не задеплоена (ProgrammingError) / коннект упал (OperationalError) /
|
||
# битый ввод (DataError) → None, analyze не падает.
|
||
db = MagicMock()
|
||
db.execute.side_effect = exc
|
||
result = get_developer_attribution(db, "66:41:0303161:1")
|
||
assert result is None
|
||
|
||
|
||
def test_unexpected_error_also_degrades_to_none() -> None:
|
||
# Любая иная ошибка (не из перечисленных) тоже не валит analyze — None.
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("boom")
|
||
result = get_developer_attribution(db, "66:41:0303161:1")
|
||
assert result is None
|