gendesign/tradein-mvp/backend/tests/test_audit_address_mismatch.py
Light1YT 02ad9a1f58 feat(tradein): Phase 1 of #582 — address mismatch audit infrastructure
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).
2026-05-25 12:50:30 +05:00

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