Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m36s
Deploy Trade-In / build-backend (push) Successful in 1m7s
Deploy Trade-In / deploy (push) Has been cancelled
240 lines
8.9 KiB
Python
240 lines
8.9 KiB
Python
"""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"]))
|