"""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