gendesign/backend/tests/services/site_finder/test_developer_attribution.py
bot-backend 39dd63333e
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 3m29s
Deploy / build-worker (push) Successful in 4m48s
Deploy / deploy (push) Successful in 1m43s
fix(site_finder): SAVEPOINT-isolate DB errors across cluster A (#2464) (#2465)
2026-07-07 12:50:11 +00:00

321 lines
15 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.

"""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
# ── SAVEPOINT-регрессия (#2464 cluster A finding 1) ──────────────────────────
def test_db_error_uses_savepoint_and_session_stays_usable() -> None:
"""Фикс: db.execute обёрнут в db.begin_nested() (SAVEPOINT), НЕ голый rollback.
db здесь — shared request-scoped Session (Depends(get_db) в parcels.py analyze_parcel);
без SAVEPOINT сбой fn_developer_for_parcel отравил бы persist_analysis_run сразу
после (parcels.py:4116). Мокаем db.execute: 1-й вызов (внутри
get_developer_attribution) кидает, 2-й (имитация persist_analysis_run следом на
том же db) отрабатывает нормально.
"""
db = MagicMock()
ok_result = MagicMock()
ok_result.mappings.return_value.all.return_value = [{"marker": "persist-ok"}]
db.execute.side_effect = [
ProgrammingError("stmt", {}, Exception("fn_developer_for_parcel does not exist")),
ok_result,
]
result = get_developer_attribution(db, "66:41:0303161:1")
assert result is None
assert db.begin_nested.call_count == 1 # SAVEPOINT вокруг резолвера
# Сессия осталась usable для следующего (не связанного) db.execute.
next_call = db.execute("SELECT 1")
assert next_call.mappings().all() == [{"marker": "persist-ok"}]
assert db.execute.call_count == 2