"""Unit tests for the Phase 2-3 backfill/audit script (issue #582). Coverage: - `forward_via_api` request shape — verifies geocode/format/locality bias. - `_parse_api_payload` precision + kind extraction. - `_classify_backfill_status` precision filter rules. - `_update_house_coords` — UPDATE shape + raw_payload merge. - `_run_backfill_mode` — happy path UPDATE + audit row, plus imprecise-skip. - `_run_audit_mode` — ok / mismatch / no_match distinction. - `main()` resumability — second pass on same batch inserts 0. No real Postgres in unit tests (same convention as test_audit_address_mismatch). DB is a MagicMock that records INSERT/UPDATE calls and routes SELECT side-effects. """ from __future__ import annotations import json import os from unittest.mock import AsyncMock, MagicMock, patch # Same dance as test_audit_address_mismatch — settings needs a DSN at import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") import httpx import pytest from scripts._yandex_reverse import ( YandexReverseResult, _parse_api_payload, forward_via_api, ) from scripts.backfill_house_coords import ( HouseRow, _classify_backfill_status, _run_audit_mode, _run_backfill_mode, _update_house_coords, main, ) # --------------------------------------------------------------------------- # forward_via_api — request shape # --------------------------------------------------------------------------- async def test_forward_api_request_shape(): """Verify the GET param dict — address as `geocode`, kind=house, EKB bias.""" fixture = { "response": { "GeoObjectCollection": { "featureMember": [ { "GeoObject": { "metaDataProperty": { "GeocoderMetaData": { "text": "Россия, Свердловская область, Екатеринбург, " "улица Малышева, 51", "precision": "exact", "kind": "house", } }, "name": "улица Малышева, 51", "Point": {"pos": "60.586155 56.838004"}, } } ] } } } captured: dict[str, httpx.Request] = {} def handler(request: httpx.Request) -> httpx.Response: captured["req"] = request return httpx.Response(200, json=fixture) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport) as client: res = await forward_via_api("ул Малышева 51", "DUMMY_KEY", client=client) assert res.address is not None and "Малышева" in res.address assert res.precision == "exact" assert res.kind == "house" assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6) assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6) qs = dict(httpx.QueryParams(captured["req"].url.query)) assert qs["apikey"] == "DUMMY_KEY" assert qs["geocode"] == "ул Малышева 51" assert qs["format"] == "json" assert qs["kind"] == "house" # EKB locality bias for forward geocode — important so addresses without # the city resolve to the correct Малышева (there's one in Moscow too). assert "ll" in qs assert "spn" in qs # --------------------------------------------------------------------------- # Precision / kind passthrough in _parse_api_payload # --------------------------------------------------------------------------- def test_parse_api_payload_propagates_precision_and_kind(): data = { "response": { "GeoObjectCollection": { "featureMember": [ { "GeoObject": { "metaDataProperty": { "GeocoderMetaData": { "text": "ул Ленина 5", "precision": "exact", "kind": "house", } }, "name": "ул Ленина 5", "Point": {"pos": "60.6 56.8"}, } } ] } } } res = _parse_api_payload(data) assert res.precision == "exact" assert res.kind == "house" # --------------------------------------------------------------------------- # _classify_backfill_status — precision filter rules # --------------------------------------------------------------------------- def test_classify_backfill_status_exact_match(): res = YandexReverseResult( address="ул Малышева 51", snapped_lat=56.838, snapped_lon=60.586, precision="exact", kind="house", ) assert _classify_backfill_status(res) == "backfill" def test_classify_backfill_status_number_match(): res = YandexReverseResult( address="ул Ленина 5", snapped_lat=56.840, snapped_lon=60.600, precision="number", kind="house", ) assert _classify_backfill_status(res) == "backfill" def test_classify_backfill_status_street_is_imprecise(): res = YandexReverseResult( address="ул Ленина", snapped_lat=56.840, snapped_lon=60.600, precision="street", kind="street", ) assert _classify_backfill_status(res) == "imprecise" def test_classify_backfill_status_other_is_imprecise(): res = YandexReverseResult( address="Свердловская область", snapped_lat=56.8, snapped_lon=60.6, precision="other", kind="locality", ) assert _classify_backfill_status(res) == "imprecise" def test_classify_backfill_status_no_match(): res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None) assert _classify_backfill_status(res) == "no_match" def test_classify_backfill_status_none(): assert _classify_backfill_status(None) == "no_match" def test_classify_backfill_status_precision_ok_but_no_coords(): """Defensive: precision=exact but snapped point missing → no_match, not backfill.""" res = YandexReverseResult( address="ул Малышева 51", snapped_lat=None, snapped_lon=None, precision="exact", kind="house", ) assert _classify_backfill_status(res) == "no_match" # --------------------------------------------------------------------------- # _update_house_coords — UPDATE shape verification # --------------------------------------------------------------------------- def test_update_house_coords_passes_bindings(): db = MagicMock() _update_house_coords( db, house_id=42, lat=56.838, lon=60.586, payload={"address": "ул Малышева 51", "precision": "exact"}, ) args, _kw = db.execute.call_args sql_str = str(args[0]) binds = args[1] assert "UPDATE houses" in sql_str assert "raw_payload" in sql_str assert "yandex_geocode" in sql_str assert binds["id"] == 42 assert binds["lat"] == 56.838 assert binds["lon"] == 60.586 # payload bound as JSON string for CAST(:payload AS jsonb) decoded = json.loads(binds["payload"]) assert decoded["address"] == "ул Малышева 51" # --------------------------------------------------------------------------- # DB mock helper — same approach as test_audit_address_mismatch # --------------------------------------------------------------------------- def _make_db_mock( backfill_sample: list[dict] | None = None, audit_sample: list[dict] | None = None, processed_ids: set[int] | None = None, distance_value: float = 12.5, ): """MagicMock DB that: - returns `backfill_sample` for `lat IS NULL OR lon IS NULL` SELECT - returns `audit_sample` for `lat IS NOT NULL` SELECT - returns `processed_ids` for the resume SELECT - records INSERTs and UPDATEs - returns `distance_value` for ST_Distance calls """ backfill_sample = backfill_sample or [] audit_sample = audit_sample or [] processed_ids = processed_ids if processed_ids is not None else set() inserted: list[dict] = [] updated: 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 "FROM houses" in sql_str and "lat IS NULL OR lon IS NULL" in sql_str: result.mappings.return_value.all.return_value = backfill_sample elif "FROM houses" in sql_str and "lat IS NOT NULL" in sql_str: result.mappings.return_value.all.return_value = audit_sample elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str: result.all.return_value = [(hid,) for hid in processed_ids] elif "INSERT INTO address_mismatch_audit" in sql_str: inserted.append(dict(params)) processed_ids.add(params["house_id"]) elif "UPDATE houses" in sql_str: updated.append(dict(params)) elif "ST_Distance" in sql_str: result.first.return_value = (distance_value,) return result db.execute.side_effect = execute_side_effect db.commit = MagicMock() db.rollback = MagicMock() db.close = MagicMock() return db, inserted, updated # --------------------------------------------------------------------------- # _run_backfill_mode — happy path + imprecise-skip # --------------------------------------------------------------------------- async def test_run_backfill_mode_writes_update_and_audit(): sample = [ HouseRow(id=1, address="ул Малышева 51", lat=None, lon=None), ] db, inserted, updated = _make_db_mock() res = YandexReverseResult( address="Россия, Екатеринбург, улица Малышева, 51", snapped_lat=56.838, snapped_lon=60.586, precision="exact", kind="house", raw={"ok": True}, ) with patch( "scripts.backfill_house_coords.forward_via_api", new=AsyncMock(return_value=res), ): n = await _run_backfill_mode(db, sample, "b1", "KEY") assert n == 1 assert len(updated) == 1 assert updated[0]["id"] == 1 assert updated[0]["lat"] == 56.838 assert updated[0]["lon"] == 60.586 assert len(inserted) == 1 assert inserted[0]["audit_status"] == "backfill" assert inserted[0]["snapped_address"] == "Россия, Екатеринбург, улица Малышева, 51" async def test_run_backfill_mode_imprecise_skips_update(): """precision='street' → audit row written with status=imprecise, no UPDATE.""" sample = [HouseRow(id=2, address="ул Ленина", lat=None, lon=None)] db, inserted, updated = _make_db_mock() res = YandexReverseResult( address="ул Ленина", snapped_lat=56.840, snapped_lon=60.600, precision="street", kind="street", raw={"oh_well": True}, ) with patch( "scripts.backfill_house_coords.forward_via_api", new=AsyncMock(return_value=res), ): n = await _run_backfill_mode(db, sample, "b2", "KEY") assert n == 1 assert updated == [] assert len(inserted) == 1 assert inserted[0]["audit_status"] == "imprecise" async def test_run_backfill_mode_no_match(): """Yandex returns empty result → status=no_match, no UPDATE.""" sample = [HouseRow(id=3, address="несуществующая улица 99", lat=None, lon=None)] db, inserted, updated = _make_db_mock() res = YandexReverseResult( address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True} ) with patch( "scripts.backfill_house_coords.forward_via_api", new=AsyncMock(return_value=res), ): n = await _run_backfill_mode(db, sample, "b3", "KEY") assert n == 1 assert updated == [] assert inserted[0]["audit_status"] == "no_match" async def test_run_backfill_mode_http_error_marks_error(): sample = [HouseRow(id=4, address="ул X 1", lat=None, lon=None)] db, inserted, updated = _make_db_mock() with patch( "scripts.backfill_house_coords.forward_via_api", new=AsyncMock(side_effect=httpx.HTTPError("boom")), ): n = await _run_backfill_mode(db, sample, "b4", "KEY") assert n == 1 assert updated == [] assert inserted[0]["audit_status"] == "error" assert "boom" in (inserted[0]["error_message"] or "") # --------------------------------------------------------------------------- # _run_audit_mode — ok / mismatch / no_match # --------------------------------------------------------------------------- async def test_run_audit_mode_ok_within_50m(): sample = [HouseRow(id=10, address="ул Малышева 51", lat=56.838, lon=60.586)] db, inserted, _updated = _make_db_mock(distance_value=12.5) res = YandexReverseResult( address="Россия, Екатеринбург, улица Малышева, 51", snapped_lat=56.838004, snapped_lon=60.586155, precision="exact", kind="house", raw={"r": 1}, ) with patch( "scripts.backfill_house_coords.reverse_via_api", new=AsyncMock(return_value=res), ): n = await _run_audit_mode(db, sample, "ba1", "KEY") assert n == 1 assert inserted[0]["audit_status"] == "ok" assert inserted[0]["distance_m"] == 12.5 async def test_run_audit_mode_mismatch_above_50m(): sample = [HouseRow(id=11, address="ул Ленина 5", lat=56.840, lon=60.600)] db, inserted, _updated = _make_db_mock(distance_value=312.0) res = YandexReverseResult( address="Россия, Екатеринбург, улица Ленина, 7", snapped_lat=56.841, snapped_lon=60.601, precision="exact", kind="house", raw={"r": 2}, ) with patch( "scripts.backfill_house_coords.reverse_via_api", new=AsyncMock(return_value=res), ): n = await _run_audit_mode(db, sample, "ba2", "KEY") assert n == 1 assert inserted[0]["audit_status"] == "mismatch" assert inserted[0]["distance_m"] == 312.0 async def test_run_audit_mode_no_match(): sample = [HouseRow(id=12, address="ул X 99", lat=56.0, lon=60.0)] db, inserted, _updated = _make_db_mock() res = YandexReverseResult( address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True} ) with patch( "scripts.backfill_house_coords.reverse_via_api", new=AsyncMock(return_value=res), ): n = await _run_audit_mode(db, sample, "ba3", "KEY") assert n == 1 assert inserted[0]["audit_status"] == "no_match" # --------------------------------------------------------------------------- # Resumability — second pass on same batch inserts 0 # --------------------------------------------------------------------------- async def test_main_resumable_skips_processed(monkeypatch): """Run main() twice with same batch — second pass processes nothing.""" backfill_sample = [ {"id": 1, "address": "ул Малышева 51", "lat": None, "lon": None}, {"id": 2, "address": "ул Ленина 5", "lat": None, "lon": None}, ] processed_ids: set[int] = set() db, inserted, updated = _make_db_mock( backfill_sample=backfill_sample, processed_ids=processed_ids ) monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") fake = YandexReverseResult( address="ул Малышева 51", snapped_lat=56.838, snapped_lon=60.586, precision="exact", kind="house", raw={"ok": True}, ) with ( patch("scripts.backfill_house_coords.SessionLocal", return_value=db), patch( "scripts.backfill_house_coords.forward_via_api", new=AsyncMock(return_value=fake), ), ): n1 = await main(["--batch", "resume_test"]) assert n1 == 2 assert len(inserted) == 2 assert len(updated) == 2 inserted.clear() updated.clear() n2 = await main(["--batch", "resume_test"]) assert n2 == 0 assert inserted == [] assert updated == [] async def test_main_requires_api_key(monkeypatch): """Without YANDEX_GEOCODER_API_KEY the script exits cleanly.""" monkeypatch.delenv("YANDEX_GEOCODER_API_KEY", raising=False) with pytest.raises(SystemExit): await main(["--batch", "no_key"]) async def test_main_audit_only_flag_routes_to_audit_loop(monkeypatch): """--audit-only switches sample query + loop, no UPDATE expected.""" audit_sample = [ {"id": 50, "address": "ул Малышева 51", "lat": 56.838, "lon": 60.586}, ] db, inserted, updated = _make_db_mock(audit_sample=audit_sample, distance_value=8.0) monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") fake = YandexReverseResult( address="Россия, Екатеринбург, улица Малышева, 51", snapped_lat=56.838004, snapped_lon=60.586155, precision="exact", kind="house", raw={"r": 1}, ) with ( patch("scripts.backfill_house_coords.SessionLocal", return_value=db), patch( "scripts.backfill_house_coords.reverse_via_api", new=AsyncMock(return_value=fake), ), ): n = await main(["--batch", "audit_run", "--audit-only"]) assert n == 1 assert updated == [] # audit mode never updates houses assert inserted[0]["audit_status"] == "ok" assert inserted[0]["distance_m"] == 8.0