All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
Финальный PR issue #2045 (BE-3): GET /api/v1/trade-in/location-coef для LocationDrawer. FDW foreign table -> локальное зеркало osm_poi_ekb_local (TRUNCATE+INSERT, тот же паттерн что cad_buildings_local/cadastral_geo_match, избегает ~1.16s/row FDW round-trip) -> straight-line POI-скоринг, портированный из Site Finder poi_score.py::compute_poi_weighted_top7 (CATEGORY_WEIGHTS as-is, радиус 1200м для квартир вместо Ptica 2000м для участков). score->coef - новая MVP-эвристика (0.95..1.05, не откалибрована на реальных дельтах). Graceful fallback (не 500, не фабрикуем факторы): пустая/не отрефрешенная osm_poi_ekb_local или отсутствие lat/lon у оценки -> coef=1.0, factors=[], geo_source="unavailable". Scheduler: source=osm_poi_ekb_refresh, daily, зарегистрирован и в боевом dispatch (scheduler.py), и в kit-registry (product_handlers.py) - иначе test_kit_registry_completeness падает на ship-dark инварианте (#2192). Frontend wiring (mappers.ts/LocationDrawer.tsx) - вне scope, отдельная задача после проверки endpoint'а curl'ом на деплое.
251 lines
9.6 KiB
Python
251 lines
9.6 KiB
Python
"""Tests for GET /api/v1/trade-in/location-coef (#2045 BE-3, LocationDrawer).
|
|
|
|
Mirrors the IDOR + mocked-DB conventions of test_estimate_idor.py / test_street_deals_endpoint.py
|
|
(no live Postgres needed). Covers:
|
|
- IDOR guard (owner / other pilot 404 / admin / unauthenticated 401)
|
|
- graceful fallback: osm_poi_ekb_local empty/not-refreshed, estimate has no lat/lon
|
|
- happy path: base_price_rub * coef → result_price_rub, factors surfaced
|
|
- radius_m query-param clamping
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
# psycopg v3 driver required; stub DATABASE_URL before any app import.
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
|
|
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import
|
|
# (trade_in.py imports generate_trade_in_pdf at module load).
|
|
_wp_mock = MagicMock()
|
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
|
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
|
|
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
|
|
|
|
import pytest # noqa: E402
|
|
from fastapi import FastAPI # noqa: E402
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_get_role():
|
|
"""Restore app.core.auth.get_role after each test (mirror test_estimate_idor)."""
|
|
from app.core import auth as auth_mod
|
|
|
|
original = auth_mod.get_role
|
|
yield
|
|
auth_mod.get_role = original
|
|
|
|
|
|
@pytest.fixture()
|
|
def trade_in_app() -> FastAPI:
|
|
"""Minimal FastAPI app mounting only the trade-in router."""
|
|
from app.api.v1 import trade_in as trade_in_module
|
|
|
|
application = FastAPI()
|
|
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
|
return application
|
|
|
|
|
|
def _client_with(app: FastAPI, db_mock: MagicMock, role: str | None) -> TestClient:
|
|
"""Override get_db with *db_mock*; patch get_role to return *role* (or raise KeyError)."""
|
|
from app.core.db import get_db
|
|
|
|
def _override_db():
|
|
yield db_mock
|
|
|
|
app.dependency_overrides[get_db] = _override_db
|
|
|
|
auth_mod = sys.modules["app.core.auth"]
|
|
if role is None:
|
|
|
|
def _raise_keyerror(_u: str):
|
|
raise KeyError(_u)
|
|
|
|
auth_mod.get_role = _raise_keyerror # type: ignore[assignment]
|
|
else:
|
|
auth_mod.get_role = lambda _u: role # type: ignore[assignment]
|
|
return TestClient(app)
|
|
|
|
|
|
def _fetchone_result(row: object) -> MagicMock:
|
|
r = MagicMock()
|
|
r.fetchone.return_value = row
|
|
return r
|
|
|
|
|
|
def _scalar_result(value: object) -> MagicMock:
|
|
r = MagicMock()
|
|
r.scalar.return_value = value
|
|
return r
|
|
|
|
|
|
def _mapping_result(rows: list[dict]) -> MagicMock:
|
|
r = MagicMock()
|
|
r.mappings.return_value.all.return_value = rows
|
|
return r
|
|
|
|
|
|
def _db_with(*results: MagicMock) -> MagicMock:
|
|
db = MagicMock()
|
|
db.execute.side_effect = list(results)
|
|
return db
|
|
|
|
|
|
# ── IDOR guard ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_location_coef_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
|
|
"""Non-owner pilot must NOT read someone else's location-coef → 404."""
|
|
db = _db_with(_fetchone_result(SimpleNamespace(created_by="victim")))
|
|
client = _client_with(trade_in_app, db, role="pilot")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID},
|
|
headers={"X-Authenticated-User": "attacker"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_location_coef_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
|
"""No X-Authenticated-User header → 401 (guard query already ran, header check fails)."""
|
|
db = _db_with(_fetchone_result(SimpleNamespace(created_by="kopylov")))
|
|
client = _client_with(trade_in_app, db, role="pilot")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_location_coef_unknown_estimate_returns_404(trade_in_app: FastAPI) -> None:
|
|
db = _db_with(_fetchone_result(None))
|
|
client = _client_with(trade_in_app, db, role="pilot")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID},
|
|
headers={"X-Authenticated-User": "kopylov"},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_location_coef_admin_can_read_any(trade_in_app: FastAPI) -> None:
|
|
"""Admin reads any estimate's location-coef regardless of owner → 200."""
|
|
db = _db_with(
|
|
_fetchone_result(SimpleNamespace(created_by="someone_else")), # guard
|
|
_fetchone_result(SimpleNamespace(lat=None, lon=None, median_price=5_000_000)), # target
|
|
)
|
|
client = _client_with(trade_in_app, db, role="admin")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID},
|
|
headers={"X-Authenticated-User": "admin"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ── Graceful fallback ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_location_coef_no_lat_lon_returns_unavailable(trade_in_app: FastAPI) -> None:
|
|
"""Estimate without lat/lon → unavailable fallback, no osm_poi_ekb_local query at all."""
|
|
db = _db_with(
|
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
_fetchone_result(SimpleNamespace(lat=None, lon=None, median_price=5_000_000)), # target
|
|
)
|
|
client = _client_with(trade_in_app, db, role="pilot")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID},
|
|
headers={"X-Authenticated-User": "kopylov"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["geo_source"] == "unavailable"
|
|
assert body["coef"] == 1.0
|
|
assert body["factors"] == []
|
|
assert body["base_price_rub"] == 5_000_000
|
|
assert body["result_price_rub"] == 5_000_000
|
|
# Short-circuits before compute_location_coef: only guard + target queries ran.
|
|
assert db.execute.call_count == 2
|
|
|
|
|
|
def test_location_coef_empty_mirror_returns_unavailable(trade_in_app: FastAPI) -> None:
|
|
"""osm_poi_ekb_local not yet refreshed (count=0) → unavailable, no fabricated factors."""
|
|
db = _db_with(
|
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
_fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target
|
|
_scalar_result(0), # osm_poi_ekb_local count
|
|
)
|
|
client = _client_with(trade_in_app, db, role="pilot")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID},
|
|
headers={"X-Authenticated-User": "kopylov"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["geo_source"] == "unavailable"
|
|
assert body["coef"] == 1.0
|
|
assert body["factors"] == []
|
|
assert body["result_price_rub"] == body["base_price_rub"]
|
|
|
|
|
|
# ── Happy path ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_location_coef_happy_path_computes_result_price(trade_in_app: FastAPI) -> None:
|
|
"""POI found within radius → coef applied to base_price_rub, factors surfaced."""
|
|
poi_rows = [
|
|
{"name": "Школа №1", "category": "school", "distance_m": 300.0},
|
|
{"name": "Метро Ботаническая", "category": "metro_stop", "distance_m": 150.0},
|
|
]
|
|
db = _db_with(
|
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
_fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target
|
|
_scalar_result(1000), # osm_poi_ekb_local count
|
|
_mapping_result(poi_rows), # nearest POI
|
|
)
|
|
client = _client_with(trade_in_app, db, role="pilot")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID},
|
|
headers={"X-Authenticated-User": "kopylov"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["geo_source"] == "osm_poi_ekb"
|
|
assert body["base_price_rub"] == 5_000_000
|
|
assert 0.95 <= body["coef"] <= 1.05
|
|
assert body["result_price_rub"] == round(5_000_000 * body["coef"])
|
|
assert len(body["factors"]) == 2
|
|
poi_types = {f["poi_type"] for f in body["factors"]}
|
|
assert poi_types == {"school", "metro_stop"}
|
|
|
|
|
|
def test_location_coef_radius_m_clamped_to_bounds(trade_in_app: FastAPI) -> None:
|
|
"""Explicit radius_m outside [500, 3000] is clamped before hitting the DB query."""
|
|
db = _db_with(
|
|
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
|
|
_fetchone_result(SimpleNamespace(lat=56.84, lon=60.6, median_price=5_000_000)), # target
|
|
_scalar_result(10), # osm_poi_ekb_local count
|
|
_mapping_result([]), # nearest POI query
|
|
)
|
|
client = _client_with(trade_in_app, db, role="pilot")
|
|
resp = client.get(
|
|
"/api/v1/trade-in/location-coef",
|
|
params={"estimate_id": _ESTIMATE_ID, "radius_m": 50},
|
|
headers={"X-Authenticated-User": "kopylov"},
|
|
)
|
|
assert resp.status_code == 200
|
|
# 4th execute() call is the nearest-POI query — inspect its bound radius_m param.
|
|
nearest_call = db.execute.call_args_list[3]
|
|
params = (
|
|
nearest_call.args[1] if len(nearest_call.args) > 1 else nearest_call.kwargs.get("params")
|
|
)
|
|
assert params["radius_m"] == 500
|