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
325 lines
13 KiB
Python
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
|