gendesign/backend/tests/services/site_finder/test_own_portfolio.py
bot-backend d3f3370bed
All checks were successful
Deploy / changes (push) Successful in 10s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m30s
Deploy / build-worker (push) Successful in 3m45s
Deploy / deploy (push) Successful in 1m54s
fix(site_finder): SAVEPOINT supply/market + convert 2 bare rollbacks (#2464 Wave 2) (#2469)
2026-07-08 06:46:09 +00:00

357 lines
14 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.

"""Тесты get_own_portfolio — унифицированный источник «наших проектов» (#1169 PR1).
Покрывает:
1. own_developer_ids пусто → current не запрашивается; только future-строки
2. own_developer_ids пусто И future пуст → [] (graceful, §25.3 деградирует на None)
3. current: мокнутые domrf-строки → OwnProject(source='current') + None-not-0
4. future: мокнутые own_planned_project-строки → OwnProject(source='future')
5. ready_dt / planned_release_month нормализуются к 1-му числу месяца
6. current-запрос параметризуется списком own_developer_ids (ANY(:ids))
7. current-SQL использует CAST(... AS integer[]), не ::int[] (psycopg v3)
8. сбой current-запроса не валит future (graceful per-source)
Mock-стратегия: DB — MagicMock; own_developer_ids патчим на singleton settings
(сервис читает его при вызове). domrf/own_planned_project запросы различаем по
db.execute.side_effect (current первым, future вторым).
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Any
from unittest.mock import MagicMock
from app.services.site_finder import own_portfolio
from app.services.site_finder.own_portfolio import (
OwnProject,
_query_current,
_query_future,
get_own_portfolio,
)
def _row(data: dict[str, Any]) -> MagicMock:
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
return m
def _result(rows: list[MagicMock]) -> MagicMock:
"""execute(...).mappings().all() → rows."""
res = MagicMock()
res.mappings.return_value.all.return_value = rows
return res
def _current_row(
obj_id: int = 100,
comm_name: str | None = "ЖК Текущий",
obj_class: str | None = "Комфорт",
district_name: str | None = "Октябрьский",
ready_dt: date | None = date(2025, 11, 20),
price_per_m2_min: Any = Decimal("130000"),
price_per_m2_max: Any = Decimal("160000"),
latitude: Any = Decimal("56.84"),
longitude: Any = Decimal("60.61"),
) -> MagicMock:
return _row(
{
"obj_id": obj_id,
"comm_name": comm_name,
"obj_class": obj_class,
"district_name": district_name,
"ready_dt": ready_dt,
"price_per_m2_min": price_per_m2_min,
"price_per_m2_max": price_per_m2_max,
"latitude": latitude,
"longitude": longitude,
}
)
def _future_row(
project_id: int = 1,
name: str = "ЖК Будущий",
obj_class: str | None = "Бизнес",
district: str | None = "Верх-Исетский",
planned_release_month: date | None = date(2027, 6, 1),
price_min_per_m2: Any = Decimal("180000"),
price_max_per_m2: Any = Decimal("220000"),
unit_mix: dict[str, float] | None = None,
lon: Any = None,
lat: Any = None,
) -> MagicMock:
return _row(
{
"id": project_id,
"name": name,
"obj_class": obj_class,
"district": district,
"planned_release_month": planned_release_month,
"price_min_per_m2": price_min_per_m2,
"price_max_per_m2": price_max_per_m2,
"unit_mix": unit_mix,
"lon": lon,
"lat": lat,
}
)
# ── empty own_developer_ids → future only / [] ──────────────────────────────────
def test_empty_developer_ids_skips_current_returns_future_only(monkeypatch) -> None:
"""own_developer_ids пусто → current НЕ запрашивается; только future-строки."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
db = MagicMock()
# Единственный execute — future-запрос (current не идёт в БД).
db.execute.return_value = _result([_future_row()])
out = get_own_portfolio(db)
assert db.execute.call_count == 1 # только future
assert len(out) == 1
assert out[0].source == "future"
assert out[0].name == "ЖК Будущий"
def test_empty_developer_ids_and_no_future_returns_empty(monkeypatch) -> None:
"""own_developer_ids пусто И future пуст → [] (graceful, не crash)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
db = MagicMock()
db.execute.return_value = _result([])
out = get_own_portfolio(db)
assert out == []
# ── current (domrf) mapping ─────────────────────────────────────────────────────
def test_current_rows_mapped_to_own_project(monkeypatch) -> None:
"""Мокнутые domrf-строки → OwnProject(source='current') с корректным маппингом."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
# current первым, future вторым (пустой).
db.execute.side_effect = [_result([_current_row()]), _result([])]
out = get_own_portfolio(db)
assert len(out) == 1
p = out[0]
assert isinstance(p, OwnProject)
assert p.source == "current"
assert p.name == "ЖК Текущий"
assert p.obj_class == "Комфорт"
assert p.district == "Октябрьский"
assert p.release_month == date(2025, 11, 1) # ready_dt → 1-е число
assert p.price_min_per_m2 == 130000.0
assert p.price_max_per_m2 == 160000.0
assert p.lat == 56.84
assert p.lon == 60.61
# current-проект не несёт квартирографии в этом запросе → None (None-not-0).
assert p.unit_mix is None
def test_current_none_fields_stay_none(monkeypatch) -> None:
"""Отсутствующие поля domrf → None (None-not-0), не 0/«»."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
row = _current_row(
comm_name="ЖК Без Цены",
obj_class=None,
district_name=None,
ready_dt=None,
price_per_m2_min=None,
price_per_m2_max=None,
latitude=None,
longitude=None,
)
db.execute.side_effect = [_result([row]), _result([])]
out = get_own_portfolio(db)
p = out[0]
assert p.obj_class is None
assert p.district is None
assert p.release_month is None
assert p.price_min_per_m2 is None
assert p.price_max_per_m2 is None
assert p.lat is None
assert p.lon is None
def test_current_query_parametrised_with_developer_ids(monkeypatch) -> None:
"""current-запрос параметризуется списком own_developer_ids (ANY(:ids))."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345, 67890])
db = MagicMock()
db.execute.side_effect = [_result([]), _result([])]
get_own_portfolio(db)
current_params = db.execute.call_args_list[0].args[1]
assert current_params["ids"] == [12345, 67890]
def test_current_sql_uses_cast_not_double_colon(monkeypatch) -> None:
"""current-SQL использует CAST(:ids AS integer[]), никогда :ids::int[] (psycopg v3)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = [_result([]), _result([])]
get_own_portfolio(db)
current_sql = str(db.execute.call_args_list[0].args[0])
assert "CAST(" in current_sql
assert "::" not in current_sql
# ── future (own_planned_project) mapping ────────────────────────────────────────
def test_future_rows_mapped_with_unit_mix(monkeypatch) -> None:
"""future-строки → OwnProject(source='future') с unit_mix-долями."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
mix = {"studio": 0.3, "1k": 0.4, "2k": 0.3}
db = MagicMock()
db.execute.return_value = _result([_future_row(unit_mix=mix)])
out = get_own_portfolio(db)
p = out[0]
assert p.source == "future"
assert p.obj_class == "Бизнес"
assert p.release_month == date(2027, 6, 1)
assert p.price_min_per_m2 == 180000.0
assert p.price_max_per_m2 == 220000.0
assert p.unit_mix == mix
def test_future_release_month_normalised_to_first(monkeypatch) -> None:
"""planned_release_month с НЕ-первым числом → нормализуется к 1-му (тайминг §25.3)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
db = MagicMock()
db.execute.return_value = _result([_future_row(planned_release_month=date(2027, 6, 17))])
out = get_own_portfolio(db)
assert out[0].release_month == date(2027, 6, 1)
# ── combined + graceful ─────────────────────────────────────────────────────────
def test_combined_current_and_future(monkeypatch) -> None:
"""current + future объединяются (current впереди future)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = [_result([_current_row()]), _result([_future_row()])]
out = get_own_portfolio(db)
assert len(out) == 2
assert out[0].source == "current"
assert out[1].source == "future"
def test_current_query_failure_does_not_break_future(monkeypatch) -> None:
"""Сбой current-запроса → current=[], future всё равно считается (graceful per-source)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]
out = get_own_portfolio(db)
assert len(out) == 1
assert out[0].source == "future"
def test_future_query_failure_returns_empty_gracefully(monkeypatch) -> None:
"""Сбой future-запроса (нет таблицы до миграции 148) → [] для future, не crash."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [])
db = MagicMock()
db.execute.side_effect = RuntimeError("relation own_planned_project does not exist")
out = get_own_portfolio(db)
assert out == []
# ── SAVEPOINT regression (#2464 cluster A) ───────────────────────────────────────
#
# db shared с caller (get_own_portfolio в цепочке §25.3 / analyze). Раньше
# _query_current/_query_future ловили db.execute-сбой в bare ``except Exception``
# без SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
# следующий db.execute на этой же сессии падает "current transaction is aborted".
# Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не эмулирует
# реальный aborted-transaction Postgres — тесты проверяют (1) graceful fallback при
# сбое, (2) что begin_nested() реально вызван вокруг db.execute, (3) что db остаётся
# usable для следующего вызова на той же mock-сессии.
class TestQueryCurrentSavepoint:
def test_uses_begin_nested_savepoint(self, monkeypatch) -> None:
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.return_value = _result([])
_query_current(db)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self, monkeypatch) -> None:
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_current(db) == []
def test_session_usable_after_failure(self, monkeypatch) -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова
на этой же сессии (имитирует _query_current → _query_future в
get_own_portfolio на одной shared Session)."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_current(db) == []
db.execute.side_effect = None
next_result = _result([{"marker": "ok"}])
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
class TestQueryFutureSavepoint:
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value = _result([])
_query_future(db)
assert db.begin_nested.call_count == 1
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("simulated DB failure")
assert _query_future(db) == []
def test_session_usable_after_failure(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _query_future(db) == []
db.execute.side_effect = None
next_result = _result([{"marker": "ok"}])
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_get_own_portfolio_current_failure_savepoint_isolated(monkeypatch) -> None:
"""End-to-end (via get_own_portfolio): current db.execute сбоит → SAVEPOINT
(не bare rollback) → future по-прежнему считается на той же сессии."""
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
db = MagicMock()
db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]
out = get_own_portfolio(db)
assert db.begin_nested.call_count >= 1
assert len(out) == 1
assert out[0].source == "future"