Stratified 200-house Yandex reverse-geocode audit comparing our DB's (address, lat/lon) pair against what Yandex.Карты returns for those coordinates. Лежит фундамент данных для Phases 2-5 (canonical = Yandex per user decision). What ships: - Migration 066: address_mismatch_audit table + FDW foreign table gendesign_ekb_districts_geom для stratified sampling. - Sampling SQL: 8 EKB districts × 25 houses = 200. - Driver scripts/audit_address_mismatch.py с двумя режимами: API (если YANDEX_GEOCODER_API_KEY есть) и Playwright (CAPTCHA-aware, персистентный контекст). Resumable по --batch. - Report SQL: p50/p75/p95 distance, top-20 outliers, per-district breakdown. - 19 unit-тестов: normalize, distance bind, API parser, resume idempotency, captcha → status=blocked. Devops note: после деплоя миграции — выдать tradein_fdw_reader SELECT на gendesign.public.ekb_districts_geom перед первым запуском audit. Refs #582 (Phase 1 of 5). Phase 3 backfill заблокирован до Yandex API key (#402).
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""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
|