"""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