All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 47s
Удаляет весь `app/services/scrapers/` (16 файлов, ~7100 строк) — Part D (D1-D5) убрал всех внешних вызывающих, 0 runtime importers подтверждено grep'ом на main. Заодно: - удалены 7 осиротевших локальных probe/sweep-скриптов (tradein-mvp/scripts/), импортировавших уже-удалённые или удаляемые сейчас legacy-модули - тест-хирургия по 65+ файлам: DELETE прямых legacy-юнит-тестов, RETARGET тестов, тестирующих ещё живую бизнес-логику (переключены на scraper_kit.* эквиваленты, включая quality-gate #781/#753/#754/#755/#773/#740), partial-delete golden-parity тестов, потерявших legacy-оракл - kit save_listings/AvitoScraper/etc. требуют инжектируемые matcher/config — ретаргетированные тесты обновлены под новую сигнатуру (RealScraperConfig(), MagicMock HouseMatcher, region_code=66) Полный pytest suite зелёный (2255 passed, 6 skipped) кроме известного флейка #2208 (test_search_cache_hit, не связан со scrapers).
201 lines
7.4 KiB
Python
201 lines
7.4 KiB
Python
"""Tests for geo_precision city-fallback flag (#769 Part E).
|
|
|
|
Two assertions:
|
|
(a) A city-fallback geocode (no house number in full_address) causes
|
|
geocode_missing_listings() to set geo_precision='city'.
|
|
(b) The radius-analog SQL in estimator._fetch_analogs contains the
|
|
geo_precision exclusion filter (AND (geo_precision IS DISTINCT FROM 'city')).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import os
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
# DATABASE_URL required by config before any app import.
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
|
|
|
# WeasyPrint stub — not installed in CI without GTK.
|
|
_wp_mock = MagicMock()
|
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from app.services.geocoder import GeocodeResult # noqa: E402
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_geo(full_address: str, confidence: str = "approximate") -> GeocodeResult:
|
|
return GeocodeResult(
|
|
lat=56.838,
|
|
lon=60.605,
|
|
full_address=full_address,
|
|
provider="nominatim", # type: ignore[arg-type]
|
|
confidence=confidence, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
# ── (a) geocode_missing sets geo_precision='city' for coarse geocode ─────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_geocode_missing_sets_city_precision_for_bare_city_address() -> None:
|
|
"""Bare city address geocoded to centroid → geo_precision='city' in UPDATE.
|
|
|
|
Simulates "Екатеринбург (Cian)" address: geocoder returns a result whose
|
|
full_address has no house-number token → _geocode_is_coarse() → True.
|
|
The UPDATE should include geo_precision='city'.
|
|
"""
|
|
from app.tasks.geocode_missing import geocode_missing_listings
|
|
|
|
# City-centroid result: full_address has no 1-3-digit house number.
|
|
city_geo = _make_geo("Екатеринбург", confidence="approximate")
|
|
|
|
rows = [{"address": "Екатеринбург (Cian)", "listings_count": 3}]
|
|
|
|
db = MagicMock()
|
|
select_result = MagicMock()
|
|
select_result.mappings.return_value.all.return_value = rows
|
|
update_result = MagicMock()
|
|
update_result.rowcount = 3
|
|
db.execute.side_effect = [select_result, update_result]
|
|
|
|
with patch(
|
|
"app.tasks.geocode_missing.geocode",
|
|
new_callable=AsyncMock,
|
|
return_value=city_geo,
|
|
):
|
|
result = await geocode_missing_listings(db, batch_size=50)
|
|
|
|
assert result.addresses_geocoded == 1
|
|
assert result.listings_updated == 3
|
|
|
|
# Inspect the UPDATE call parameters: geo_precision must be 'city'.
|
|
update_call = db.execute.call_args_list[1]
|
|
update_params = update_call[0][1] # positional arg[1] = params dict
|
|
assert (
|
|
update_params.get("precision") == "city"
|
|
), f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_geocode_missing_sets_none_precision_for_precise_address() -> None:
|
|
"""Address with house number → _geocode_is_coarse() → False → geo_precision=None."""
|
|
from app.tasks.geocode_missing import geocode_missing_listings
|
|
|
|
# Precise result: full_address contains house number "30".
|
|
precise_geo = _make_geo("Екатеринбург, улица Малышева, 30", confidence="exact")
|
|
|
|
rows = [{"address": "ул. Малышева, 30", "listings_count": 2}]
|
|
|
|
db = MagicMock()
|
|
select_result = MagicMock()
|
|
select_result.mappings.return_value.all.return_value = rows
|
|
update_result = MagicMock()
|
|
update_result.rowcount = 2
|
|
db.execute.side_effect = [select_result, update_result]
|
|
|
|
with patch(
|
|
"app.tasks.geocode_missing.geocode",
|
|
new_callable=AsyncMock,
|
|
return_value=precise_geo,
|
|
):
|
|
await geocode_missing_listings(db, batch_size=50)
|
|
|
|
update_call = db.execute.call_args_list[1]
|
|
update_params = update_call[0][1]
|
|
assert (
|
|
update_params.get("precision") is None
|
|
), f"Expected precision=None for precise address, got {update_params.get('precision')!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_geocode_missing_sets_city_precision_for_locality_confidence() -> None:
|
|
"""confidence='locality' → _geocode_is_coarse() → True → geo_precision='city'."""
|
|
from app.tasks.geocode_missing import geocode_missing_listings
|
|
|
|
# confidence='locality' is an explicit centroid marker (even if address has digits).
|
|
locality_geo = _make_geo("Екатеринбург, Свердловская область", confidence="locality")
|
|
|
|
rows = [{"address": "Екатеринбург (N1)", "listings_count": 1}]
|
|
|
|
db = MagicMock()
|
|
select_result = MagicMock()
|
|
select_result.mappings.return_value.all.return_value = rows
|
|
update_result = MagicMock()
|
|
update_result.rowcount = 1
|
|
db.execute.side_effect = [select_result, update_result]
|
|
|
|
with patch(
|
|
"app.tasks.geocode_missing.geocode",
|
|
new_callable=AsyncMock,
|
|
return_value=locality_geo,
|
|
):
|
|
await geocode_missing_listings(db, batch_size=50)
|
|
|
|
update_call = db.execute.call_args_list[1]
|
|
update_params = update_call[0][1]
|
|
assert (
|
|
update_params.get("precision") == "city"
|
|
), f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}"
|
|
|
|
|
|
# ── (b) estimator radius-analog SQL contains geo_precision exclusion ──────────
|
|
|
|
|
|
def test_fetch_analogs_tier_w_excludes_city_precision() -> None:
|
|
"""Tier W (wide radius) query must filter out geo_precision='city' listings.
|
|
|
|
Asserts the SQL fragment is present in _fetch_analogs source code so that
|
|
city-centroid listings are excluded from radius analog matching.
|
|
"""
|
|
from app.services import estimator
|
|
|
|
source = inspect.getsource(estimator._fetch_analogs)
|
|
assert "geo_precision IS DISTINCT FROM 'city'" in source, (
|
|
"Tier W radius-analog query must contain "
|
|
"AND (geo_precision IS DISTINCT FROM 'city') to exclude city-centroid listings"
|
|
)
|
|
|
|
|
|
def test_fetch_analogs_tier_h_excludes_city_precision() -> None:
|
|
"""Tier H (same class, radius) query must also filter out geo_precision='city'."""
|
|
from app.services import estimator
|
|
|
|
source = inspect.getsource(estimator._fetch_analogs)
|
|
# The filter appears in both Tier H and Tier W sub-queries.
|
|
# Count occurrences to verify both are patched.
|
|
occurrences = source.count("geo_precision IS DISTINCT FROM 'city'")
|
|
assert occurrences >= 2, (
|
|
f"Expected geo_precision filter in both Tier H and Tier W queries, "
|
|
f"found {occurrences} occurrence(s)"
|
|
)
|
|
|
|
|
|
def test_scraped_lot_has_geo_precision_field() -> None:
|
|
"""ScrapedLot has geo_precision field with default None."""
|
|
from scraper_kit.base import ScrapedLot
|
|
|
|
lot = ScrapedLot(
|
|
source="cian",
|
|
source_url="https://cian.ru/sale/flat/123/",
|
|
price_rub=5_000_000,
|
|
)
|
|
assert hasattr(lot, "geo_precision")
|
|
assert lot.geo_precision is None
|
|
|
|
|
|
def test_scraped_lot_accepts_city_geo_precision() -> None:
|
|
"""ScrapedLot accepts geo_precision='city'."""
|
|
from scraper_kit.base import ScrapedLot
|
|
|
|
lot = ScrapedLot(
|
|
source="n1",
|
|
source_url="https://n1.ru/123/",
|
|
price_rub=3_000_000,
|
|
geo_precision="city",
|
|
)
|
|
assert lot.geo_precision == "city"
|