"""Unit tests for issue #569 Step 3 — geocode_deals_nominatim.py. Coverage (per the issue's test plan): - a NULL-coords deal gets geocoded → UPDATE deals issued with lat/lon + geocode_tried_at, counted as geocoded, deals_updated reflects rowcount. - geocode() returns None → geocode_tried_at still stamped (a "mark tried" UPDATE with no lat/lon), no coords UPDATE, counted as geocode_failed. - --dry-run issues no UPDATE / no commit, counters still move. - the resume filter excludes already-tried-recently rows (SQL carries the lat IS NULL + staleness predicate, and binds --stale-days through). - per-address SAVEPOINT isolation: one failing UPDATE doesn't abort the batch. - dedup: distinct addresses drive geocode call count (one call per address). - main() wiring: SessionLocal, --limit bind, returns geocoded count. No real Postgres. `geocode()` is async → patched with AsyncMock (mirrors tests/tasks/test_geocode_missing.py). The Session is a MagicMock that routes SELECT side-effects by SQL substring and records UPDATE binds (mirrors tests/scripts/test_geocode_deals_from_houses.py). """ from __future__ import annotations import os from unittest.mock import AsyncMock, MagicMock, patch # Settings needs a DSN at import time — set a dummy before any app.* import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services.geocoder import GeocodeResult from scripts.geocode_deals_nominatim import ( AddressGroup, Stats, _mark_deals_tried, _run_backfill, _select_pending_addresses, _update_deals_geocoded, main, ) _GEOCODE_PATH = "scripts.geocode_deals_nominatim.geocode" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _result(provider: str = "nominatim", lat: float = 56.838, lon: float = 60.605) -> GeocodeResult: return GeocodeResult( lat=lat, lon=lon, full_address="Екатеринбург, ул. Тестовая", provider=provider, # type: ignore[arg-type] confidence="approximate", ) def _make_db_mock( *, address_rows: list[dict] | None = None, total_streets: int = 0, total_rows: int = 0, update_rowcount: int = 1, ) -> tuple[MagicMock, list[dict], list[dict]]: """MagicMock Session that: - returns `address_rows` for the candidate SELECT (`FROM deals` GROUP BY) - returns (total_streets, total_rows) for the dry-run COUNT - records coords-UPDATE binds into `coord_updates` - records mark-tried-UPDATE binds into `tried_updates` - every UPDATE returns a result with `.rowcount == update_rowcount` - supports `db.begin_nested()` as a context manager Returns (db, coord_updates, tried_updates). """ address_rows = address_rows or [] coord_updates: list[dict] = [] tried_updates: list[dict] = [] db = MagicMock() db.begin_nested.return_value.__enter__ = lambda self: self db.begin_nested.return_value.__exit__ = lambda self, *a: False def execute_side_effect(sql, params=None): sql_str = str(sql) result = MagicMock() if "UPDATE deals" in sql_str: # Distinguish the coords UPDATE (sets lat) from the mark-tried UPDATE. if "SET lat" in sql_str: coord_updates.append(dict(params) if params else {}) else: tried_updates.append(dict(params) if params else {}) result.rowcount = update_rowcount return result if "COUNT(DISTINCT address)" in sql_str: result.first.return_value = (total_streets, total_rows) return result if "FROM deals" in sql_str: result.mappings.return_value.all.return_value = address_rows return result return result db.execute.side_effect = execute_side_effect db.commit = MagicMock() db.rollback = MagicMock() db.close = MagicMock() return db, coord_updates, tried_updates # --------------------------------------------------------------------------- # _run_backfill — happy path: geocoded → coords UPDATE + tried_at stamped # --------------------------------------------------------------------------- async def test_run_backfill_geocoded_issues_coords_update(): groups = [AddressGroup(address="Екатеринбург, Тестовая", deals_count=5)] db, coord_updates, tried_updates = _make_db_mock(update_rowcount=5) with patch(_GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("nominatim")): stats = await _run_backfill(db, groups, batch="b1", dry_run=False) assert stats.geocoded == 1 assert stats.geocode_failed == 0 assert stats.processed == 1 assert stats.deals_updated == 5 assert stats.cache_misses == 1 assert stats.cache_hits == 0 # Exactly one coords UPDATE, bound with lat/lon/addr; no mark-tried UPDATE. assert len(coord_updates) == 1 assert coord_updates[0]["addr"] == "Екатеринбург, Тестовая" assert coord_updates[0]["lat"] == 56.838 assert coord_updates[0]["lon"] == 60.605 assert tried_updates == [] assert db.commit.call_count == 1 async def test_run_backfill_cache_hit_counted_separately(): groups = [AddressGroup(address="Екатеринбург, Кэшевая", deals_count=2)] db, coord_updates, _ = _make_db_mock(update_rowcount=2) with patch(_GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("cache")): stats = await _run_backfill(db, groups, batch="b", dry_run=False) assert stats.geocoded == 1 assert stats.cache_hits == 1 assert stats.cache_misses == 0 assert len(coord_updates) == 1 # --------------------------------------------------------------------------- # geocode None → geocode_tried_at stamped, NO coords UPDATE, counted failed # --------------------------------------------------------------------------- async def test_run_backfill_geocode_none_stamps_tried_no_coords(): groups = [AddressGroup(address="Екатеринбург, Несуществующая", deals_count=3)] db, coord_updates, tried_updates = _make_db_mock(update_rowcount=3) with patch(_GEOCODE_PATH, new_callable=AsyncMock, return_value=None): stats = await _run_backfill(db, groups, batch="b", dry_run=False) assert stats.geocode_failed == 1 assert stats.geocoded == 0 assert stats.processed == 1 assert stats.deals_updated == 0 # No coords UPDATE… assert coord_updates == [] # …but geocode_tried_at IS stamped via the mark-tried UPDATE. assert len(tried_updates) == 1 assert tried_updates[0]["addr"] == "Екатеринбург, Несуществующая" assert db.commit.call_count == 1 async def test_mark_deals_tried_sql_stamps_tried_at_only(): """The mark-tried writer sets geocode_tried_at and nothing else (no lat/lon).""" db = MagicMock() db.execute.return_value = MagicMock(rowcount=4) n = _mark_deals_tried(db, address="Екатеринбург, X") args, _kw = db.execute.call_args sql_str = str(args[0]) assert "UPDATE deals" in sql_str assert "geocode_tried_at = NOW()" in sql_str assert "SET lat" not in sql_str assert "lat IS NULL" in sql_str # idempotency / no-clobber guard assert n == 4 async def test_run_backfill_geocode_raises_not_stamped(): """A raised geocode error → counted failed but NOT stamped (retry sooner).""" groups = [AddressGroup(address="Екатеринбург, Сетевая", deals_count=1)] db, coord_updates, tried_updates = _make_db_mock() with patch(_GEOCODE_PATH, new_callable=AsyncMock, side_effect=RuntimeError("network")): stats = await _run_backfill(db, groups, batch="b", dry_run=False) assert stats.geocode_failed == 1 assert stats.geocoded == 0 # Neither a coords UPDATE nor a mark-tried UPDATE — leave it for next run. assert coord_updates == [] assert tried_updates == [] assert db.commit.call_count == 0 # --------------------------------------------------------------------------- # --dry-run — no DB writes # --------------------------------------------------------------------------- async def test_run_backfill_dry_run_issues_no_update(): groups = [ AddressGroup(address="Екатеринбург, Тестовая", deals_count=4), AddressGroup(address="Екатеринбург, Пустая", deals_count=1), ] db, coord_updates, tried_updates = _make_db_mock() # First address resolves, second is a miss — neither must write in dry-run. with patch( _GEOCODE_PATH, new_callable=AsyncMock, side_effect=[_result("nominatim"), None], ): stats = await _run_backfill(db, groups, batch="dry", dry_run=True) assert stats.geocoded == 1 assert stats.geocode_failed == 1 assert stats.processed == 2 assert coord_updates == [] assert tried_updates == [] assert db.commit.call_count == 0 # --------------------------------------------------------------------------- # dedup — one geocode call per distinct address # --------------------------------------------------------------------------- async def test_run_backfill_one_call_per_distinct_address(): groups = [ AddressGroup(address="Екатеринбург, А", deals_count=10), AddressGroup(address="Екатеринбург, Б", deals_count=2), ] db, coord_updates, _ = _make_db_mock(update_rowcount=1) with patch( _GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("nominatim") ) as mock_geo: stats = await _run_backfill(db, groups, batch="b", dry_run=False) # 2 distinct addresses → exactly 2 geocode calls (not 12 = sum of deals). assert mock_geo.call_count == 2 assert stats.geocoded == 2 assert len(coord_updates) == 2 async def test_run_backfill_skips_too_short_address_without_geocoding(): groups = [AddressGroup(address="ек", deals_count=1)] # < 3 chars db, coord_updates, _ = _make_db_mock() with patch(_GEOCODE_PATH, new_callable=AsyncMock) as mock_geo: stats = await _run_backfill(db, groups, batch="b", dry_run=False) assert stats.skipped == 1 assert stats.processed == 0 mock_geo.assert_not_called() assert coord_updates == [] # --------------------------------------------------------------------------- # per-address SAVEPOINT — one bad UPDATE doesn't abort the batch # --------------------------------------------------------------------------- async def test_run_backfill_db_write_failure_isolated_to_address(): groups = [ AddressGroup(address="Екатеринбург, Первая", deals_count=1), AddressGroup(address="Екатеринбург, Вторая", deals_count=1), ] db, coord_updates, _ = _make_db_mock(update_rowcount=1) # Make the FIRST coords UPDATE raise, the rest succeed. call = {"n": 0} real_side_effect = db.execute.side_effect def failing_execute(sql, params=None): sql_str = str(sql) if "UPDATE deals" in sql_str and "SET lat" in sql_str: call["n"] += 1 if call["n"] == 1: raise RuntimeError("constraint blew up") return real_side_effect(sql, params) db.execute.side_effect = failing_execute with patch(_GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("nominatim")): stats = await _run_backfill(db, groups, batch="b", dry_run=False) # Both addresses geocoded; one write failed but was isolated via SAVEPOINT. assert stats.geocoded == 2 assert db.rollback.call_count == 1 # Only the second address's coords UPDATE was recorded. assert [u["addr"] for u in coord_updates] == ["Екатеринбург, Вторая"] # deals_updated only counts the successful write. assert stats.deals_updated == 1 # --------------------------------------------------------------------------- # _update_deals_geocoded — bind shape + geom NOT set manually # --------------------------------------------------------------------------- def test_update_deals_geocoded_sets_lat_lon_tried_at_not_geom(): db = MagicMock() db.execute.return_value = MagicMock(rowcount=7) n = _update_deals_geocoded(db, address="Екатеринбург, Y", lat=56.1, lon=60.2) args, _kw = db.execute.call_args sql_str = str(args[0]) binds = args[1] assert "UPDATE deals" in sql_str assert "geocode_tried_at = NOW()" in sql_str # geom must NOT be set manually — the deals_set_geom_trg trigger fills it. assert "geom" not in sql_str assert "lat IS NULL" in sql_str # no-clobber guard assert binds == {"addr": "Екатеринбург, Y", "lat": 56.1, "lon": 60.2} assert n == 7 # --------------------------------------------------------------------------- # resume filter — candidate SQL carries lat IS NULL + staleness predicate # --------------------------------------------------------------------------- def test_select_pending_addresses_filters_null_and_stale(): """The candidate query must combine lat IS NULL with the staleness window and bind --stale-days, so already-tried-recently rows are excluded.""" db, _, _ = _make_db_mock( address_rows=[{"address": "Екатеринбург, Z", "deals_count": 3}] ) groups = _select_pending_addresses(db, limit=2000, stale_days=30) assert groups == [AddressGroup(address="Екатеринбург, Z", deals_count=3)] # Inspect the SQL + binds of the SELECT. select_call = next( c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0]) ) sql_str = str(select_call[0][0]) binds = select_call[0][1] assert "lat IS NULL" in sql_str assert "geocode_tried_at IS NULL" in sql_str assert "make_interval(days => CAST(:stale_days AS int))" in sql_str assert "GROUP BY address" in sql_str assert binds["stale_days"] == 30 assert binds["limit"] == 2000 def test_select_pending_addresses_passes_custom_stale_days(): db, _, _ = _make_db_mock(address_rows=[]) _select_pending_addresses(db, limit=500, stale_days=7) select_call = next( c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0]) ) binds = select_call[0][1] assert binds["stale_days"] == 7 assert binds["limit"] == 500 # --------------------------------------------------------------------------- # main() — wiring, --limit, --dry-run, return value # --------------------------------------------------------------------------- async def test_main_geocodes_and_returns_count(): address_rows = [ {"address": "Екатеринбург, Малышева", "deals_count": 4}, {"address": "Екатеринбург, Ленина", "deals_count": 2}, ] db, coord_updates, _ = _make_db_mock(address_rows=address_rows, update_rowcount=4) with ( patch("scripts.geocode_deals_nominatim.SessionLocal", return_value=db), patch(_GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("nominatim")), ): n = await main(["--batch", "test_main"]) assert n == 2 # both distinct addresses geocoded assert len(coord_updates) == 2 assert {u["addr"] for u in coord_updates} == { "Екатеринбург, Малышева", "Екатеринбург, Ленина", } async def test_main_dry_run_writes_nothing(): address_rows = [{"address": "Екатеринбург, Малышева", "deals_count": 3}] db, coord_updates, tried_updates = _make_db_mock( address_rows=address_rows, total_streets=1, total_rows=3 ) with ( patch("scripts.geocode_deals_nominatim.SessionLocal", return_value=db), patch(_GEOCODE_PATH, new_callable=AsyncMock, return_value=_result("nominatim")), ): n = await main(["--dry-run"]) # dry-run returns the would-geocode count, writes nothing. assert n == 1 assert coord_updates == [] assert tried_updates == [] assert db.commit.call_count == 0 async def test_main_respects_limit_bind(): db, _, _ = _make_db_mock(address_rows=[]) with ( patch("scripts.geocode_deals_nominatim.SessionLocal", return_value=db), patch(_GEOCODE_PATH, new_callable=AsyncMock), ): await main(["--limit", "50"]) select_call = next( c for c in db.execute.call_args_list if "FROM deals" in str(c[0][0]) ) assert select_call[0][1]["limit"] == 50 async def test_main_no_pending_returns_zero(): db, coord_updates, _ = _make_db_mock(address_rows=[]) with ( patch("scripts.geocode_deals_nominatim.SessionLocal", return_value=db), patch(_GEOCODE_PATH, new_callable=AsyncMock) as mock_geo, ): n = await main([]) assert n == 0 assert coord_updates == [] mock_geo.assert_not_called() db.close.assert_called_once() # --------------------------------------------------------------------------- # Stats dataclass # --------------------------------------------------------------------------- def test_stats_defaults_zero(): s = Stats() assert s.processed == 0 assert s.geocoded == 0 assert s.geocode_failed == 0 assert s.skipped == 0 assert s.deals_updated == 0 assert s.cache_hits == 0 assert s.cache_misses == 0