gendesign/backend/tests/services/site_finder/test_own_portfolio.py
Light1YT df34e55ab4
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m25s
CI / backend-tests (pull_request) Successful in 6m22s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m12s
feat(site-finder): own-portfolio data source for §25.3 cannibalization (#1169 PR1)
Foundation PR: unified "our projects" source the §25.3 overlap engine (PR2)
will consume. Two origins normalized to OwnProject (class/timing/price/unit-mix):
- current <- domrf_kn_objects filtered by settings.own_developer_ids (numeric
  prefix of composite dev_id; empty -> [] graceful, no DB hit, no hardcoded id)
- future  <- new manual-entry own_planned_project entity (migration 148)

Adds OWN_DEVELOPER_IDS config (comma-sep -> list[int], default []),
own_planned_project table (range/unit_mix CHECKs via IMMUTABLE helper, generated
geom), /api/v1/own-projects CRUD (created_by from X-Authenticated-User), and
get_own_portfolio(db). Per-source graceful degradation; psycopg-v3 CAST clean.

Does not touch special_indices.py or parcels.py (out of scope).

Refs #1169
2026-06-08 16:21:53 +05:00

274 lines
10 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, 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 == []