189 lines
7.6 KiB
Python
189 lines
7.6 KiB
Python
"""Tests for _fetch_analogs per-address cap and per-source quota (source starvation fix).
|
||
|
||
Regression: Монтёрская 8/2 — 91 Avito listings с distance=0 выдавливали
|
||
Cian/Yandex/N1 из топ-50, т.к. pure-distance sort + LIMIT 50.
|
||
Fix: MAX_ANALOGS_PER_ADDRESS cap в SQL + MIN_ANALOGS_PER_SOURCE quota в Python.
|
||
"""
|
||
import os
|
||
|
||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
def _make_listing(
|
||
*,
|
||
source: str,
|
||
address: str,
|
||
distance_m: float,
|
||
relevance_score: float | None = None,
|
||
price_rub: float = 5_000_000.0,
|
||
area_m2: float = 38.0,
|
||
rooms: int = 1,
|
||
) -> dict[str, Any]:
|
||
"""Construct a minimal listing dict mimicking DB mapping output."""
|
||
if relevance_score is None:
|
||
relevance_score = distance_m / 1000.0
|
||
return {
|
||
"source": source,
|
||
"source_url": f"https://{source}.ru/offer/1",
|
||
"address": address,
|
||
"lat": 56.838,
|
||
"lon": 60.595,
|
||
"rooms": rooms,
|
||
"area_m2": area_m2,
|
||
"floor": 3,
|
||
"total_floors": 16,
|
||
"price_rub": price_rub,
|
||
"price_per_m2": price_rub / area_m2,
|
||
"listing_date": datetime(2026, 5, 1),
|
||
"days_on_market": 10,
|
||
"photo_urls": [],
|
||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||
"distance_m": distance_m,
|
||
"relevance_score": relevance_score,
|
||
}
|
||
|
||
|
||
def _make_db_mock(rows: list[dict[str, Any]]) -> MagicMock:
|
||
"""Build a Session mock where db.execute().mappings().all() returns rows."""
|
||
db = MagicMock()
|
||
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||
return db
|
||
|
||
|
||
# ── Test 1: per-address cap ───────────────────────────────────────────────────
|
||
|
||
def test_address_cap_limits_per_address_listings() -> None:
|
||
"""_fetch_analogs caps at MAX_ANALOGS_PER_ADDRESS listings from a single address.
|
||
|
||
SQL already applies rn_addr <= MAX_ANALOGS_PER_ADDRESS via window function.
|
||
This test verifies the Python post-processing does not accidentally bypass
|
||
the cap by confirming that when SQL returns exactly MAX_ANALOGS_PER_ADDRESS
|
||
rows per address, the result contains no more than that.
|
||
"""
|
||
from app.services.estimator import MAX_ANALOGS_PER_ADDRESS, _fetch_analogs
|
||
|
||
# SQL has already applied rn_addr <= MAX_ANALOGS_PER_ADDRESS.
|
||
# Simulate: SQL returns exactly MAX_ANALOGS_PER_ADDRESS avito rows (cap enforced).
|
||
addr = "ул. Монтёрская, 8/2"
|
||
sql_rows = [
|
||
_make_listing(source="avito", address=addr, distance_m=0.0, relevance_score=float(i))
|
||
for i in range(MAX_ANALOGS_PER_ADDRESS)
|
||
]
|
||
db = _make_db_mock(sql_rows)
|
||
|
||
result, fallback_used, _tier = _fetch_analogs(
|
||
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
|
||
)
|
||
|
||
avito_from_addr = [r for r in result if r["source"] == "avito" and r["address"] == addr]
|
||
assert len(avito_from_addr) <= MAX_ANALOGS_PER_ADDRESS, (
|
||
f"Expected at most {MAX_ANALOGS_PER_ADDRESS} avito from same address, "
|
||
f"got {len(avito_from_addr)}"
|
||
)
|
||
assert fallback_used is False
|
||
|
||
|
||
# ── Test 2: source quota (regression for Cian starvation) ────────────────────
|
||
|
||
def test_source_quota_prevents_cian_starvation() -> None:
|
||
"""MIN_ANALOGS_PER_SOURCE guarantees Cian is represented despite Avito dominance.
|
||
|
||
Regression: Монтёрская 8/2 — 60 Avito @ distance=0 + 8 Cian @ distance=200m.
|
||
Before fix: LIMIT 50 → 50 Avito, 0 Cian.
|
||
After fix: result contains >= min(8, MIN_ANALOGS_PER_SOURCE) Cian.
|
||
"""
|
||
from app.services.estimator import MIN_ANALOGS_PER_SOURCE, _fetch_analogs
|
||
|
||
# SQL already applied address cap. Simulate SQL result after cap:
|
||
# 5 avito (cap applied to large block), 8 cian (different address, 200m away).
|
||
avito_rows = [
|
||
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
|
||
relevance_score=float(i) * 0.01)
|
||
for i in range(5)
|
||
]
|
||
cian_rows = [
|
||
_make_listing(source="cian", address="ул. Монтёрская, 1", distance_m=200.0,
|
||
relevance_score=0.2 + float(i) * 0.01)
|
||
for i in range(8)
|
||
]
|
||
# SQL returns avito first (better relevance), then cian
|
||
sql_rows = avito_rows + cian_rows
|
||
db = _make_db_mock(sql_rows)
|
||
|
||
result, _, _ = _fetch_analogs(
|
||
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
|
||
)
|
||
|
||
cian_count = sum(1 for r in result if r["source"] == "cian")
|
||
expected_min = min(8, MIN_ANALOGS_PER_SOURCE)
|
||
assert cian_count >= expected_min, (
|
||
f"Expected >= {expected_min} Cian in result, got {cian_count}. "
|
||
"Source starvation bug not fixed."
|
||
)
|
||
|
||
|
||
# ── Test 3: no source starvation when quota > supply ─────────────────────────
|
||
|
||
def test_source_quota_includes_all_when_supply_below_min() -> None:
|
||
"""When a source has fewer listings than MIN_ANALOGS_PER_SOURCE, all are included.
|
||
|
||
Seed: 5 avito (after cap) + 3 cian @ 300m. All 3 cian must appear in result.
|
||
"""
|
||
from app.services.estimator import _fetch_analogs
|
||
|
||
avito_rows = [
|
||
_make_listing(source="avito", address="ул. Монтёрская, 8/2", distance_m=0.0,
|
||
relevance_score=float(i) * 0.01)
|
||
for i in range(5)
|
||
]
|
||
cian_rows = [
|
||
_make_listing(source="cian", address="ул. Монтёрская, 3", distance_m=300.0,
|
||
relevance_score=0.3 + float(i) * 0.01)
|
||
for i in range(3)
|
||
]
|
||
sql_rows = avito_rows + cian_rows
|
||
db = _make_db_mock(sql_rows)
|
||
|
||
result, _, _ = _fetch_analogs(
|
||
db, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=1000
|
||
)
|
||
|
||
cian_count = sum(1 for r in result if r["source"] == "cian")
|
||
assert cian_count == 3, (
|
||
f"All 3 Cian listings (below MIN quota) must be included, got {cian_count}"
|
||
)
|
||
assert len(result) == 8 # 5 avito + 3 cian
|
||
|
||
|
||
# ── Test 4: fallback signal preserved ────────────────────────────────────────
|
||
|
||
def test_fallback_signal_reflects_radius() -> None:
|
||
"""_fetch_analogs returns correct fallback_used boolean based on radius_m.
|
||
|
||
fallback_used=False when radius_m == DEFAULT_RADIUS_M (1000).
|
||
fallback_used=True when radius_m == FALLBACK_RADIUS_M (2000).
|
||
"""
|
||
from app.services.estimator import DEFAULT_RADIUS_M, FALLBACK_RADIUS_M, _fetch_analogs
|
||
|
||
rows = [
|
||
_make_listing(source="avito", address="ул. Ленина, 1", distance_m=100.0,
|
||
relevance_score=0.1),
|
||
]
|
||
|
||
db_default = _make_db_mock(rows)
|
||
_, fallback_default, _ = _fetch_analogs(
|
||
db_default, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=DEFAULT_RADIUS_M
|
||
)
|
||
assert fallback_default is False, "radius == DEFAULT should produce fallback_used=False"
|
||
|
||
db_fallback = _make_db_mock(rows)
|
||
_, fallback_wide, _ = _fetch_analogs(
|
||
db_fallback, lat=56.838, lon=60.595, rooms=1, area=38.0, radius_m=FALLBACK_RADIUS_M
|
||
)
|
||
assert fallback_wide is True, "radius == FALLBACK should produce fallback_used=True"
|