Code-review follow-up: /recompute hardcoded price_source="objective_district_median" for any body-supplied market_price_per_sqm, mislabeling the honesty-flag once Stage 2b forwards a price whose genuine source differs (objective_geo_radius / district_reference / class_norm from financial_estimate). - schemas/concept.py: add optional price_source: str | None to MassingProgram. - api/v1/concepts.py: on FAST path use payload.price_source if provided, else the default label (now a module-level constant _DEFAULT_PRERESOLVED_SOURCE). DB-fallback and class-norm paths keep their own resolved source unchanged. - tests: assert body-provided price_source echoes through to financial.price_source (not overwritten), and the default label applies when the front omits it.
162 lines
7.3 KiB
Python
162 lines
7.3 KiB
Python
"""Stage 2a (#1965, эпик #1953) — POST /api/v1/concepts/recompute endpoint tests.
|
||
|
||
LIVE-пересчёт экономики из агрегированной массинг-программы (пятно × этажность) для
|
||
дебаунс-слайдеров Stage 2b. Проверяем: 200 + связный вывод (ТЭП-математика, revenue>0,
|
||
NPV посчитан), FAST path с предрезолвленной ценой (БЕЗ обращения к БД) и DB-fallback по
|
||
центроиду (через dependency override get_db мок-сессией). compute_financial остаётся
|
||
чистой — её арифметика покрыта в tests/services/generative; тут — контракт эндпоинта.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.core.db import get_db
|
||
from app.main import app
|
||
|
||
|
||
def _program(**overrides: Any) -> dict[str, Any]:
|
||
base: dict[str, Any] = {
|
||
"total_footprint_sqm": 3000.0,
|
||
"floors": 12,
|
||
"sections": 2,
|
||
"site_area_sqm": 10_000.0,
|
||
"housing_class": "comfort",
|
||
"development_type": "mid_rise",
|
||
}
|
||
base.update(overrides)
|
||
return base
|
||
|
||
|
||
def test_recompute_returns_200_and_coherent_output() -> None:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/concepts/recompute",
|
||
json=_program(market_price_per_sqm=150_000.0),
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
teap = body["teap"]
|
||
fin = body["financial"]
|
||
# ТЭП-математика: GFA = пятно × этажность; пятно = total_footprint_sqm.
|
||
assert teap["built_area_sqm"] == 3000.0
|
||
assert teap["total_floor_area_sqm"] == 3000.0 * 12
|
||
# Финмодель связна: выручка/затраты > 0, NPV посчитан (поле присутствует).
|
||
assert fin["revenue_rub"] > 0
|
||
assert fin["cost_rub"] > 0
|
||
assert isinstance(fin["npv_rub"], float)
|
||
|
||
|
||
def test_recompute_market_price_passthrough_skips_db() -> None:
|
||
# Предрезолвленная цена в теле → используется как есть, БЕЗ обращения к БД.
|
||
# get_db переопределён сессией, которая ВЗРЫВАЕТСЯ при любом execute — если бы
|
||
# эндпоинт пошёл в БД, тест бы упал. Зелёный тест доказывает skip DB lookup.
|
||
class _ExplodingSession:
|
||
def execute(self, *_a: object, **_k: object) -> None:
|
||
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
|
||
|
||
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/concepts/recompute",
|
||
json=_program(market_price_per_sqm=180_000.0),
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
fin = resp.json()["financial"]
|
||
# Цена прокинута в выручку жилья → price_per_sqm_used == переданная цена.
|
||
assert fin["price_per_sqm_used"] == 180_000.0
|
||
assert fin["price_is_calibrated"] is True
|
||
# Без body price_source → дефолтный лейбл (фронт не форвардил подлинный source).
|
||
assert fin["price_source"] == "objective_district_median"
|
||
finally:
|
||
app.dependency_overrides.pop(get_db, None)
|
||
|
||
|
||
def test_recompute_body_price_source_echoed_through() -> None:
|
||
# Фронт форвардит ПОДЛИННЫЙ source цены (из financial_estimate, напр. geo_radius) —
|
||
# эндпоинт НЕ подменяет его захардкоженным дефолтом; honest-флаг доходит до UI/PDF.
|
||
# get_db взрывается → доказывает, что и source-passthrough идёт по FAST path без БД.
|
||
class _ExplodingSession:
|
||
def execute(self, *_a: object, **_k: object) -> None:
|
||
raise AssertionError("DB lookup must be skipped when market_price_per_sqm given")
|
||
|
||
app.dependency_overrides[get_db] = lambda: _ExplodingSession()
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/concepts/recompute",
|
||
json=_program(market_price_per_sqm=160_000.0, price_source="objective_geo_radius"),
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
fin = resp.json()["financial"]
|
||
assert fin["price_per_sqm_used"] == 160_000.0
|
||
assert fin["price_is_calibrated"] is True
|
||
# Подлинный source эхнут наружу, НЕ перетёрт дефолтом "objective_district_median".
|
||
assert fin["price_source"] == "objective_geo_radius"
|
||
finally:
|
||
app.dependency_overrides.pop(get_db, None)
|
||
|
||
|
||
def test_recompute_db_fallback_when_no_price_but_centroid() -> None:
|
||
# Нет market_price_per_sqm, но передан centroid → эндпоинт делает _lookup_market_price.
|
||
# Мок-сессия отдаёт район + Objective-медиану (n>=10) → цена калибруется из БД.
|
||
class _Result:
|
||
def __init__(self, row: dict[str, Any] | None) -> None:
|
||
self._row = row
|
||
|
||
def mappings(self) -> _Result:
|
||
return self
|
||
|
||
def first(self) -> dict[str, Any] | None:
|
||
return self._row
|
||
|
||
class _FakeSession:
|
||
def __init__(self, rows: list[dict[str, Any] | None]) -> None:
|
||
self._rows = rows
|
||
self.calls = 0
|
||
|
||
def execute(self, *_a: object, **_k: object) -> _Result:
|
||
row = self._rows[self.calls] if self.calls < len(self._rows) else None
|
||
self.calls += 1
|
||
return _Result(row)
|
||
|
||
rows = [
|
||
{"district_name": "Кировский", "median_price_per_m2": 130_000},
|
||
{"median_ppm2": 175_000.0, "sample_size": 42},
|
||
]
|
||
app.dependency_overrides[get_db] = lambda: _FakeSession(rows)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.post(
|
||
"/api/v1/concepts/recompute",
|
||
json=_program(parcel_centroid_wkt="POINT (60.6 56.83)"),
|
||
)
|
||
assert resp.status_code == 200, resp.text
|
||
fin = resp.json()["financial"]
|
||
# Цена пришла из Objective-медианы района (DB-fallback).
|
||
assert fin["price_per_sqm_used"] == 175_000.0
|
||
assert fin["price_source"] == "objective_district_median"
|
||
assert fin["price_is_calibrated"] is True
|
||
finally:
|
||
app.dependency_overrides.pop(get_db, None)
|
||
|
||
|
||
def test_recompute_no_price_no_centroid_uses_class_norm() -> None:
|
||
# Ни цены, ни центроида → норматив класса comfort (145_000), не калибровано.
|
||
client = TestClient(app)
|
||
resp = client.post("/api/v1/concepts/recompute", json=_program())
|
||
assert resp.status_code == 200, resp.text
|
||
fin = resp.json()["financial"]
|
||
assert fin["price_per_sqm_used"] == 145_000.0
|
||
assert fin["price_is_calibrated"] is False
|
||
assert fin["price_source"] == "class_norm"
|
||
|
||
|
||
def test_recompute_validates_floors_bounds() -> None:
|
||
# floors вне [1, 40] → 422 (Pydantic-валидация контракта).
|
||
client = TestClient(app)
|
||
resp = client.post("/api/v1/concepts/recompute", json=_program(floors=99))
|
||
assert resp.status_code == 422
|