"""Tests for GET /estimate/{id}/sell-time-sensitivity (#1995). Regression coverage for the "недостаточно данных" honesty flag: buckets built from a thin sample (n_lots below settings.sell_time_sensitivity_min_n_lots) must carry insufficient_data=True instead of silently exposing noisy median/p25/p75 exposure_days numbers. We deliberately do NOT try to smooth or "fix" a non-monotonic result (e.g. +10% selling faster than +5%) — see test_sell_time_sensitivity_does_not_smooth_non_monotonic_result below. """ 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. _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 def _make_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 _exec_result( *, fetchone: object | None = None, all_rows: list | None = None, scalar: object | None = None, mapping_rows: list | None = None, ) -> MagicMock: """Build a single db.execute(...) return value supporting whichever chain the endpoint calls next (.fetchone() / .all() / .scalar() / .mappings().all()).""" result = MagicMock() result.fetchone.return_value = fetchone result.all.return_value = all_rows if all_rows is not None else [] result.scalar.return_value = scalar mapping_mock = MagicMock() mapping_mock.all.return_value = mapping_rows if mapping_rows is not None else [] result.mappings.return_value = mapping_mock return result def _make_db_mock( *, created_by: str | None, bucket_rows: list[dict], target_median: int | None = 120_000, ) -> MagicMock: """Sequential db.execute() mock covering the endpoint's fixed 6-call path (explicit radius_m, address resolves ≥1 house_id): 1. _assert_estimate_access_by_id → fetchone(created_by) 2. target lat/lon/address → fetchone 3. address-based house lookup → all() 4. radius_m-based house expansion → all() 5. target_median benchmark → scalar() 6. bucket_rows → mappings().all() """ db = MagicMock() db.execute.side_effect = [ _exec_result(fetchone=SimpleNamespace(created_by=created_by)), _exec_result( fetchone=SimpleNamespace(lat=56.8, lon=60.6, address="Екатеринбург, ул. Тестовая, 1") ), _exec_result(all_rows=[SimpleNamespace(id=1)]), _exec_result(all_rows=[SimpleNamespace(id=2)]), _exec_result(scalar=target_median), _exec_result(mapping_rows=bucket_rows), ] return db def _client_with(app: FastAPI, db_mock: MagicMock, role: str) -> TestClient: 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"] auth_mod.get_role = lambda _u: role # type: ignore[assignment] return TestClient(app) def _bucket_row(bucket: str, n_lots: int, median_exposure_days: int) -> dict: return { "bucket": bucket, "n_lots": n_lots, "median_exposure_days": median_exposure_days, "p25_days": median_exposure_days - 5, "p75_days": median_exposure_days + 5, } def _get(client: TestClient, *, radius_m: int = 500) -> dict: resp = client.get( f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/sell-time-sensitivity", params={"radius_m": radius_m}, headers={"X-Authenticated-User": "admin"}, ) assert resp.status_code == 200, resp.text return resp.json() # ── Test: thin bucket (n_lots < threshold) flagged insufficient_data ──────── def test_sell_time_sensitivity_flags_thin_bucket_insufficient() -> None: """n_lots below settings.sell_time_sensitivity_min_n_lots (default 10) → insufficient_data=True; buckets with plenty of lots stay False.""" from app.core.config import settings bucket_rows = [ _bucket_row("cheap", 20, 30), _bucket_row("median", 18, 35), # plus5 — thin sample (3 lots). _bucket_row("plus5", 3, 60), _bucket_row("plus10", 15, 40), ] db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) app = _make_app() client = _client_with(app, db_mock, role="admin") data = _get(client) by_label = {b["price_premium_label"]: b for b in data["buckets"]} assert by_label["cheap"]["n_lots"] == 20 assert by_label["cheap"]["insufficient_data"] is False assert by_label["plus5"]["n_lots"] == 3 assert by_label["plus5"]["insufficient_data"] is True assert by_label["plus5"]["n_lots"] < settings.sell_time_sensitivity_min_n_lots # ── Test: bucket at exactly the threshold is NOT flagged (strict <) ───────── def test_sell_time_sensitivity_bucket_at_threshold_not_flagged() -> None: """n_lots == threshold (10) is considered sufficient (strict less-than check).""" from app.core.config import settings bucket_rows = [ _bucket_row("cheap", settings.sell_time_sensitivity_min_n_lots, 25), _bucket_row("median", 20, 30), _bucket_row("plus5", 20, 35), _bucket_row("plus10", 20, 40), ] db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) app = _make_app() client = _client_with(app, db_mock, role="admin") data = _get(client) by_label = {b["price_premium_label"]: b for b in data["buckets"]} assert by_label["cheap"]["n_lots"] == settings.sell_time_sensitivity_min_n_lots assert by_label["cheap"]["insufficient_data"] is False # ── Test: missing bucket (no DB rows at all) defaults n_lots=0 → flagged ──── def test_sell_time_sensitivity_missing_bucket_flagged() -> None: """A price bucket absent from the DB result (no matching lots) still comes back with n_lots=0 and MUST be flagged insufficient_data — never a bare 0 presented as if it were a real, confident median.""" bucket_rows = [ _bucket_row("cheap", 20, 30), _bucket_row("median", 18, 35), _bucket_row("plus10", 15, 40), # 'plus5' entirely missing from bucket_rows. ] db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) app = _make_app() client = _client_with(app, db_mock, role="admin") data = _get(client) by_label = {b["price_premium_label"]: b for b in data["buckets"]} assert by_label["plus5"]["n_lots"] == 0 assert by_label["plus5"]["median_exposure_days"] is None assert by_label["plus5"]["insufficient_data"] is True # ── Test: non-monotonic result is surfaced as-is, not smoothed ────────────── def test_sell_time_sensitivity_does_not_smooth_non_monotonic_result() -> None: """#1995: +10% selling (median_exposure_days=20) FASTER than +5% (median_exposure_days=60) is a real, honestly-reported small-sample artifact — the endpoint must NOT invent smoothing/interpolation. Both thin buckets are simply flagged insufficient_data so the frontend can choose to de-emphasize them, and the raw (non-monotonic) numbers pass through unchanged.""" bucket_rows = [ _bucket_row("cheap", 20, 30), _bucket_row("median", 18, 35), _bucket_row("plus5", 4, 60), # thin — slower _bucket_row("plus10", 3, 20), # thin — faster (non-monotonic vs plus5) ] db_mock = _make_db_mock(created_by="kopylov", bucket_rows=bucket_rows) app = _make_app() client = _client_with(app, db_mock, role="admin") data = _get(client) by_label = {b["price_premium_label"]: b for b in data["buckets"]} # Raw numbers unchanged — no smoothing/reordering applied. assert by_label["plus5"]["median_exposure_days"] == 60 assert by_label["plus10"]["median_exposure_days"] == 20 # Both thin buckets honestly flagged instead of silently shown. assert by_label["plus5"]["insufficient_data"] is True assert by_label["plus10"]["insufficient_data"] is True if __name__ == "__main__": # pragma: no cover raise SystemExit(pytest.main([__file__, "-q"]))