"""Тесты 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, 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 == []