"""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 app.services.scrapers.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 app.services.scrapers.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"