"""Unit tests for the Phase-1 address-mismatch audit (issue #582). Coverage: - `_first_street_token` / `_street_differs` — normalization-driven diff. - `_distance_meters` — verified against a MagicMock'd DB that returns a canned distance, plus a Haversine cross-check on the bind values to catch lat/lon swaps. - `reverse_via_api` via httpx MockTransport with the fixture file. - `main()` resumability — call twice with the same batch, second call inserts 0 (uses MagicMock DB session). Why no real Postgres in unit tests: The repo doesn't bundle pytest-postgresql / testcontainers and the existing tests all use `MagicMock` for the DB. We follow that convention here. The distance and SQL-level resumability are validated by: - The Haversine cross-check (pure-Python expected ≈ PostGIS result for same coords, see `test_distance_calc_matches_haversine`). - Calling `main()` twice in `test_audit_script_resumable` — first run inserts N rows, second run sees the same set of house_ids in the "already processed" query and processes 0. """ from __future__ import annotations import json import math import os from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch # Settings requires DATABASE_URL at init time — set dummy DSN before any # `app.*` import (same pattern as test_cian_valuation.py). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") import httpx import pytest from scripts._yandex_reverse import ( YandexBlockedError, YandexReverseResult, _parse_api_payload, reverse_via_api, ) from scripts.audit_address_mismatch import ( SampleRow, _distance_meters, _first_street_token, _resolve_mode, _run_api_mode, _street_differs, main, ) _FIXTURES = Path(__file__).parent / "fixtures" # --------------------------------------------------------------------------- # _first_street_token / _street_differs # --------------------------------------------------------------------------- def test_normalize_address_street_token_basic(): """First identifying token of a normalized address — skips street type.""" assert _first_street_token("ул Малышева 51") == "малышева" def test_normalize_address_street_token_skips_leading_numbers(): """Numeric tokens are skipped — the street name carries identity.""" # No type prefix → first non-numeric token is the street name itself. assert _first_street_token("123 Постовского") == "постовского" def test_normalize_address_street_token_handles_none(): assert _first_street_token(None) is None assert _first_street_token("") is None def test_street_differs_true_when_streets_differ(): assert _street_differs("ул Малышева 51", "ул Ленина 51") is True def test_street_differs_false_when_same_after_normalization(): # 'ул' expands to 'улица' on both sides → same first token. assert _street_differs("ул Малышева 51", "улица Малышева, 51") is False def test_street_differs_none_on_empty_side(): assert _street_differs(None, "ул Малышева 51") is None assert _street_differs("ул Малышева 51", "") is None # --------------------------------------------------------------------------- # _distance_meters — MagicMock DB + Haversine cross-check # --------------------------------------------------------------------------- def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Reference implementation for sanity-checking the PostGIS call.""" r = 6_371_000.0 p1 = math.radians(lat1) p2 = math.radians(lat2) dp = math.radians(lat2 - lat1) dl = math.radians(lon2 - lon1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * r * math.asin(math.sqrt(a)) def test_distance_calc_passes_correct_bindings(): """Test the helper passes lat/lon in correct order to the SQL bind names.""" db = MagicMock() # PostGIS would return one row, single column (distance in meters). db.execute.return_value.first.return_value = (123.45,) out = _distance_meters(db, 56.838, 60.586, 56.840, 60.590) assert out == 123.45 # Verify the bind dict — guard against lat/lon swap regressions. args, _kwargs = db.execute.call_args bound = args[1] assert bound == { "olat": 56.838, "olon": 60.586, "slat": 56.840, "slon": 60.590, } def test_distance_calc_returns_none_when_postgis_null(): """ST_Distance can return NULL — caller must propagate None, not 0.""" db = MagicMock() db.execute.return_value.first.return_value = (None,) assert _distance_meters(db, 56.0, 60.0, 56.0, 60.0) is None def test_distance_calc_matches_haversine_within_tolerance(): """Sanity check: if PostGIS returned 555.7m for a known pair, that's within ~1% of the Haversine reference (PostGIS uses Vincenty on geography which is slightly more accurate).""" expected = _haversine_m(56.838, 60.586, 56.843, 60.591) # Just assert reference is in a sensible range — proves the test helper # works; the actual call is mocked. assert 500 < expected < 700 # --------------------------------------------------------------------------- # Yandex API: payload parsing + reverse_via_api with MockTransport # --------------------------------------------------------------------------- def test_yandex_parse_api_fixture(): """Sanity check: parse the bundled fixture into a YandexReverseResult.""" data = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8")) res = _parse_api_payload(data) assert res.address is not None assert "Малышева" in res.address # Fixture Point.pos = "60.586155 56.838004" → lon then lat. assert res.snapped_lon == pytest.approx(60.586155, abs=1e-6) assert res.snapped_lat == pytest.approx(56.838004, abs=1e-6) assert res.raw == data def test_yandex_parse_api_no_match(): """Empty featureMember → all-None result, raw still preserved.""" data = {"response": {"GeoObjectCollection": {"featureMember": []}}} res = _parse_api_payload(data) assert res.address is None assert res.snapped_lat is None assert res.snapped_lon is None assert res.raw == data async def test_yandex_reverse_api_mock(): """End-to-end: reverse_via_api hits a MockTransport, returns parsed result.""" fixture = json.loads((_FIXTURES / "yandex_geocode_sample.json").read_text("utf-8")) 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 reverse_via_api(56.838004, 60.586155, "DUMMY_KEY", client=client) assert res.address is not None and "Малышева" in res.address # Verify the request shape — lon,lat order + apikey + kind=house. req = captured["req"] qs = dict(httpx.QueryParams(req.url.query)) assert qs["apikey"] == "DUMMY_KEY" assert qs["geocode"] == "60.586155,56.838004" assert qs["format"] == "json" assert qs["kind"] == "house" async def test_yandex_blocked_error_raised_on_captcha(): """`reverse_via_playwright` must raise YandexBlockedError on captcha. We mock the page object so we don't need an actual browser. """ from scripts._yandex_reverse import reverse_via_playwright page = MagicMock() page.goto = AsyncMock() page.wait_for_load_state = AsyncMock() page.query_selector = AsyncMock( side_effect=lambda sel: MagicMock() if sel == ".CheckboxCaptcha" else None ) page.evaluate = AsyncMock(return_value=[None, None]) with pytest.raises(YandexBlockedError): await reverse_via_playwright(56.838, 60.586, page) # --------------------------------------------------------------------------- # Mode resolver # --------------------------------------------------------------------------- def test_resolve_mode_auto_with_key(): assert _resolve_mode("auto", "abc") == "api" def test_resolve_mode_auto_without_key(): assert _resolve_mode("auto", None) == "playwright" assert _resolve_mode("auto", "") == "playwright" def test_resolve_mode_explicit_passes_through(): assert _resolve_mode("api", None) == "api" assert _resolve_mode("playwright", "abc") == "playwright" # --------------------------------------------------------------------------- # Resumability — main() twice with same batch # --------------------------------------------------------------------------- def _make_db_mock(initial_sample: list[dict], processed_ids: set[int]): """Build a MagicMock SQLAlchemy session that: - returns `initial_sample` for the sampling SQL (text() with limit_per_district) - returns `processed_ids` for the resume SQL (text() with batch only) - records INSERTs so the test can count them """ inserted: 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 h" in sql_str or "houses_in_districts" in sql_str: result.mappings.return_value.all.return_value = initial_sample elif "FROM address_mismatch_audit" in sql_str and "house_id" in sql_str: # Resume query — returns list of (house_id,) tuples. result.all.return_value = [(hid,) for hid in processed_ids] elif "INSERT INTO address_mismatch_audit" in sql_str: inserted.append(dict(params)) # Simulate ON CONFLICT DO NOTHING — track id locally for re-run. processed_ids.add(params["house_id"]) result = MagicMock() elif "ST_Distance" in sql_str: result.first.return_value = (42.0,) else: result = MagicMock() return result db.execute.side_effect = execute_side_effect db.commit = MagicMock() db.rollback = MagicMock() db.close = MagicMock() return db, inserted async def test_audit_script_resumable(monkeypatch): """Run main() twice with the same batch — second pass inserts 0.""" sample = [ { "id": 1, "address": "ул Малышева 51", "lat": 56.838, "lon": 60.586, "district": "Кировский", }, {"id": 2, "address": "ул Ленина 5", "lat": 56.840, "lon": 60.600, "district": "Ленинский"}, ] processed_ids: set[int] = set() db, inserted = _make_db_mock(sample, processed_ids) # Force API mode without needing a real key. monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") fake_result = YandexReverseResult( address="Россия, Екатеринбург, улица Малышева, 51", snapped_lat=56.838004, snapped_lon=60.586155, raw={"ok": True}, ) with ( patch("scripts.audit_address_mismatch.SessionLocal", return_value=db), patch( "scripts.audit_address_mismatch.reverse_via_api", new=AsyncMock(return_value=fake_result), ), ): # First run — both rows processed. n1 = await main(["--batch", "test_batch_1", "--mode", "api"]) assert n1 == 2 assert len(inserted) == 2 # Second run with same batch — nothing left to do. inserted.clear() n2 = await main(["--batch", "test_batch_1", "--mode", "api"]) assert n2 == 0 assert inserted == [] async def test_audit_script_api_mode_marks_error(monkeypatch): """When the reverse call raises, the row is still inserted with status=error.""" sample = [ { "id": 99, "address": "ул Малышева 51", "lat": 56.838, "lon": 60.586, "district": "Кировский", }, ] processed_ids: set[int] = set() db, inserted = _make_db_mock(sample, processed_ids) monkeypatch.setenv("YANDEX_GEOCODER_API_KEY", "TEST_KEY") with ( patch("scripts.audit_address_mismatch.SessionLocal", return_value=db), patch( "scripts.audit_address_mismatch.reverse_via_api", new=AsyncMock(side_effect=httpx.HTTPError("boom")), ), ): n = await main(["--batch", "err_batch", "--mode", "api"]) assert n == 1 assert len(inserted) == 1 assert inserted[0]["audit_status"] == "error" assert "boom" in (inserted[0]["error_message"] or "") # --------------------------------------------------------------------------- # Internal _run_api_mode no-match path # --------------------------------------------------------------------------- async def test_api_mode_no_match_path(): """If Yandex returns address=None, row goes in with status=no_match.""" sample = [SampleRow(id=7, address="ул X 1", lat=56.0, lon=60.0, district="Кировский")] processed_ids: set[int] = set() db, inserted = _make_db_mock([], processed_ids) res = YandexReverseResult(address=None, snapped_lat=None, snapped_lon=None, raw={"empty": True}) with patch( "scripts.audit_address_mismatch.reverse_via_api", new=AsyncMock(return_value=res), ): n = await _run_api_mode(db, sample, "b1", "key") assert n == 1 assert inserted[0]["audit_status"] == "no_match" assert inserted[0]["snapped_address"] is None