357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""Тесты 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"
|