gendesign/tradein-mvp/backend/tests/test_location_index_endpoint.py
lekss361 580be61914
All checks were successful
Deploy / build-backend (push) Successful in 6m30s
Deploy / build-worker (push) Successful in 6m44s
Deploy / changes (push) Successful in 11s
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy (push) Successful in 2m26s
Deploy Trade-In / deploy (push) Successful in 3m10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m6s
Deploy Trade-In / test (push) Successful in 5m5s
fix(tradein/location): заменить сломанный коэффициент локации на калиброванный индекс (#2531)
2026-07-26 21:48:15 +00:00

325 lines
13 KiB
Python

"""Tests for GET /api/v1/trade-in/location-index (replaces test_location_coef_endpoint.py).
Mirrors the IDOR + mocked-DB conventions of test_estimate_idor.py / the deleted
test_location_coef_endpoint.py (no live Postgres needed). Covers:
- IDOR guard (owner / other pilot 404 / admin / unauthenticated 401)
- honest degradation: no lat/lon, point outside EKB coverage, insufficient comparable
sample, empty osm_poi_ekb_local mirror (independent of the index status)
- happy path: status="ok", location_index_pct computed, nearby_poi 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"
_LAT_IN_EKB = 56.838
_LON_IN_EKB = 60.605
@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_all_result(rows: list[dict]) -> MagicMock:
r = MagicMock()
r.mappings.return_value.all.return_value = rows
return r
def _mapping_one_result(row: dict | None) -> MagicMock:
r = MagicMock()
r.mappings.return_value.first.return_value = row
return r
def _db_with(*results: MagicMock) -> MagicMock:
db = MagicMock()
db.execute.side_effect = list(results)
return db
# ── IDOR guard ────────────────────────────────────────────────────────────────
def test_location_index_other_pilot_gets_404(trade_in_app: FastAPI) -> None:
"""Non-owner pilot must NOT read someone else's location-index → 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-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "attacker"},
)
assert resp.status_code == 404
def test_location_index_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-index",
params={"estimate_id": _ESTIMATE_ID},
)
assert resp.status_code == 401
def test_location_index_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-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 404
def test_location_index_admin_can_read_any(trade_in_app: FastAPI) -> None:
"""Admin reads any estimate's location-index regardless of owner → 200."""
db = _db_with(
_fetchone_result(SimpleNamespace(created_by="someone_else")), # guard
_fetchone_result(SimpleNamespace(lat=None, lon=None)), # target — no lat/lon
)
client = _client_with(trade_in_app, db, role="admin")
resp = client.get(
"/api/v1/trade-in/location-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "admin"},
)
assert resp.status_code == 200
# ── Honest degradation ────────────────────────────────────────────────────────
def test_location_index_no_lat_lon_returns_out_of_coverage(trade_in_app: FastAPI) -> None:
"""Estimate without lat/lon → out_of_coverage, no compute_location_index DB call at all."""
db = _db_with(
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
_fetchone_result(SimpleNamespace(lat=None, lon=None)), # target
)
client = _client_with(trade_in_app, db, role="pilot")
resp = client.get(
"/api/v1/trade-in/location-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "out_of_coverage"
assert body["location_index_pct"] is None
assert body["nearby_poi"] == []
assert body["poi_status"] == "unavailable"
# Short-circuits before compute_location_index: only guard + target queries ran.
assert db.execute.call_count == 2
def test_location_index_point_outside_ekb_bbox_returns_out_of_coverage(
trade_in_app: FastAPI,
) -> None:
"""Estimate has lat/lon, but outside the EKB coverage bbox (e.g. Nizhny Tagil)."""
db = _db_with(
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
_fetchone_result(SimpleNamespace(lat=57.910, lon=59.970)), # target — Nizhny Tagil
)
client = _client_with(trade_in_app, db, role="pilot")
resp = client.get(
"/api/v1/trade-in/location-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "out_of_coverage"
assert body["location_index_pct"] is None
# compute_location_index short-circuits — no further DB calls beyond guard + target.
assert db.execute.call_count == 2
def test_location_index_insufficient_sample_returns_no_fabricated_number(
trade_in_app: FastAPI,
) -> None:
"""Comparable sample stays below MIN_SAMPLE_SIZE even at max radius → insufficient_data,
never a noisy number computed from a handful of listings."""
db = _db_with(
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
_fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
_scalar_result(0), # osm_poi_ekb_local count
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
_mapping_one_result({"median_ppm2": 200_000.0, "n": 5}), # 800m
_mapping_one_result({"median_ppm2": 195_000.0, "n": 12}), # 1500m
_mapping_one_result({"median_ppm2": 190_000.0, "n": 15}), # 2500m — still short
)
client = _client_with(trade_in_app, db, role="pilot")
resp = client.get(
"/api/v1/trade-in/location-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "insufficient_data"
assert body["location_index_pct"] is None
assert body["local_median_price_per_m2"] is None
assert body["sample_size"] == 15
assert body["city_median_price_per_m2"] == 150_000
def test_location_index_poi_mirror_empty_does_not_block_index(trade_in_app: FastAPI) -> None:
"""poi_status="unavailable" is independent of status="ok" — an un-refreshed POI mirror
must not prevent a perfectly computable price-based index."""
db = _db_with(
_fetchone_result(SimpleNamespace(created_by="kopylov")), # guard
_fetchone_result(SimpleNamespace(lat=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
_scalar_result(0), # osm_poi_ekb_local count == 0
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
_mapping_one_result({"median_ppm2": 172_500.0, "n": 40}), # 800m — sufficient
)
client = _client_with(trade_in_app, db, role="pilot")
resp = client.get(
"/api/v1/trade-in/location-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["location_index_pct"] == 15.0
assert body["poi_status"] == "unavailable"
assert body["nearby_poi"] == []
# ── Happy path ────────────────────────────────────────────────────────────────
def test_location_index_happy_path(trade_in_app: FastAPI) -> None:
"""Comparable sample found → index computed, nearby POI surfaced as qualitative info."""
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=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
_scalar_result(1000), # osm_poi_ekb_local count
_mapping_all_result(poi_rows), # nearest POI
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
_mapping_one_result({"median_ppm2": 165_000.0, "n": 25}), # 800m — sufficient
)
client = _client_with(trade_in_app, db, role="pilot")
resp = client.get(
"/api/v1/trade-in/location-index",
params={"estimate_id": _ESTIMATE_ID},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["poi_status"] == "ok"
assert body["location_index_pct"] == 10.0
assert body["local_median_price_per_m2"] == 165_000
assert body["city_median_price_per_m2"] == 150_000
assert body["sample_size"] == 25
assert body["radius_m"] == 800
assert len(body["nearby_poi"]) == 2
poi_types = {f["poi_type"] for f in body["nearby_poi"]}
assert poi_types == {"school", "metro_stop"}
# New contract must NOT resurrect the misleading price-multiplier fields.
assert "coef" not in body
assert "base_price_rub" not in body
assert "result_price_rub" not in body
def test_location_index_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=_LAT_IN_EKB, lon=_LON_IN_EKB)), # target
_scalar_result(0), # osm_poi_ekb_local count
_mapping_one_result({"median_ppm2": 150_000.0, "n": 4000}), # citywide
_mapping_one_result({"median_ppm2": 160_000.0, "n": 50}), # explicit radius query
)
client = _client_with(trade_in_app, db, role="pilot")
resp = client.get(
"/api/v1/trade-in/location-index",
params={"estimate_id": _ESTIMATE_ID, "radius_m": 50},
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["radius_m"] == 500
# 5th execute() call (index 4) is the explicit-radius local-median query.
nearest_call = db.execute.call_args_list[4]
params = (
nearest_call.args[1] if len(nearest_call.args) > 1 else nearest_call.kwargs.get("params")
)
assert params["radius_m"] == 500