"""Tests for GET /api/v1/trade-in/street-deals endpoint. Covers: - extract_street_name parsing (various address formats) - empty result when no DB rows match - correct aggregation (median/count) with fixture deals """ import os import sys from unittest.mock import MagicMock # psycopg v3 driver (psycopg2 not installed) os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") # WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import. _wp_mock = MagicMock() sys.modules.setdefault("weasyprint", _wp_mock) sys.modules.setdefault("weasyprint.CSS", _wp_mock) sys.modules.setdefault("weasyprint.HTML", _wp_mock) from datetime import date # noqa: E402 import pytest # noqa: E402 from fastapi import FastAPI # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 # ── Unit: extract_street_name ───────────────────────────────────────────────── def test_extract_street_name_basic() -> None: """Various address formats should yield the bare street name.""" from app.services.estimator import extract_street_name cases = [ ( "Свердловская обл., г. Екатеринбург, ул. Космонавтов, 12", "Космонавтов", ), ( "г. Екатеринбург, проспект Ленина, 50", "Ленина", ), ( "Екатеринбург, ул. 8 Марта, 18", "8 Марта", ), ( "Россия, Екатеринбург, пер. Красный, 4", "Красный", ), ( "Екатеринбург, ул. Малышева, 1", "Малышева", ), ] for addr, expected in cases: result = extract_street_name(addr) assert result == expected, f"addr={addr!r}: expected {expected!r}, got {result!r}" def test_extract_street_name_none_and_empty() -> None: from app.services.estimator import extract_street_name assert extract_street_name(None) is None assert extract_street_name("") is None def test_extract_street_name_no_street_keyword() -> None: """Address without a recognisable street keyword — must not crash.""" from app.services.estimator import extract_street_name result = extract_street_name("Industrial zone X") assert result is None or isinstance(result, str) @pytest.mark.parametrize( "address,expected", [ # Forward format (user-typed) ("Екатеринбург, ул. Космонавтов, 50", "Космонавтов"), ("Россия, Екатеринбург, ул. Малышева, 1", "Малышева"), ("проспект Ленина 50", "Ленина"), ("ул. Большая Конюшенная, 25", "Большая Конюшенная"), # Reverse format (from Nominatim geocoder) ( "80, улица 8 Марта, Артек, Форум-Сити, Ленинский район, Екатеринбург," " городской округ Екатеринбург, Свердловская область," " Уральский федеральный округ, 640144, Россия", "8 Марта", ), ( "50, улица Космонавтов, Орджоникидзевский район, Екатеринбург, Россия", "Космонавтов", ), # Edge cases ("", None), (None, None), ("just plain text", None), ("Москва", None), ], ) def test_extract_street_name_parametrized(address: str | None, expected: str | None) -> None: """Parametrized cases covering forward, reverse (Nominatim), and edge cases.""" from app.services.estimator import extract_street_name assert extract_street_name(address) == expected @pytest.mark.parametrize( "address,expected", [ # Live-prod repro (2026-09-12): московский формат — keyword стоит # ПОСЛЕ имени улицы ("Название улица, N"), а не перед ним. Сразу # после keyword идёт запятая — _STREET_KW_RE (форматы "ул. X") # такую позицию вообще не матчит (требует \s+), из-за чего # extract_street_name возвращал None для ВСЕХ московских адресов, # и ДКП-коридор (_fetch_dkp_corridor) для Москвы никогда не строился # (212 937 московских сделок в БД, dkp_corridor=null на проде). ("Москва, Тверская улица, 6", "Тверская"), ("Москва, 1-я Тверская-Ямская улица, 12", "1-я Тверская-Ямская"), ("Москва, Брюсов переулок, 8", "Брюсов"), ("Москва, Ленинский проспект, 30", "Ленинский"), # Reverse-формат Nominatim с московским порядком keyword'а. ( "Театр имени М. Н. Ермоловой, 5/6, Тверская улица, 58, Тверской" " район, Москва, Центральный федеральный округ, 125009, Россия", "Тверская", ), # Площадь — тот же реверс-порядок, keyword не только "улица"/"переулок". ("Москва, Красная площадь, 1", "Красная"), # Новая Москва — Coммунарка/поселения — тот же keyword-порядок работает # без явного упоминания слова "Москва" в адресе. ("поселение Сосенское, Коммунарка, Ясная улица, 5", "Ясная"), # Голый keyword без имени перед ним (пустая запятая-секция) — извлекать # нечего, а не "" или мусор из соседнего сегмента. ("Москва, улица, 5", None), # Нумерованный проезд БЕЗ имени: "Проектируемый" — прилагательное к # самому "проезду" (сотни таких проездов в Москве имеют один и тот же # префикс), keyword+"№" — НЕ moscow-reverse формат с именем. ("Москва, Проектируемый проезд № 4062, 5", None), ], ) def test_extract_street_name_moscow_reverse_order( address: str | None, expected: str | None ) -> None: """Московский формат "Название улица, N" — keyword после имени, не перед ним (#dkp-corridor live-prod fix 2026-09-12).""" from app.services.estimator import extract_street_name assert extract_street_name(address) == expected @pytest.mark.parametrize( "address,expected", [ # Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a # trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading # postal index + admin parts ("620105, Свердловская обл, г # Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required # the dot → keyword never matched on ANY DaData address → street-deals # / sales-vs-listings endpoints silently returned empty for every # DaData-geocoded request, not just this one. ( "620105, Свердловская обл, г Екатеринбург, Академический р-н, " "ул Академика Парина, д 46/5", "Академика Парина", ), # Same address, WITH the dot — must give the identical result (dot # optional, not dot-forbidden). ( "620105, Свердловская обл, г Екатеринбург, Академический р-н, " "ул. Академика Парина, д 46/5", "Академика Парина", ), # Same address, full word "улица" — the alternation-order/backtracking # concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave # "ица ..." behind. ( "620105, Свердловская обл, г Екатеринбург, Академический р-н, " "улица Академика Парина, д 46/5", "Академика Парина", ), # Without the leading postal index — same admin prefix otherwise. ( "Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5", "Академика Парина", ), # Bare street+house, no admin prefix at all. ("ул Академика Парина, д 46/5", "Академика Парина"), # Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр). ("г Екатеринбург, пр Ленина, 5", "Ленина"), ("г Екатеринбург, пер Красный, 4", "Красный"), ("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"), # "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression). ("Екатеринбург, ул. Малышева, 1", "Малышева"), ("Екатеринбург, ул Малышева, 1", "Малышева"), ("Екатеринбург, улица Малышева, 1", "Малышева"), ], ) def test_extract_street_name_dadata_no_dot_abbreviations( address: str | None, expected: str | None ) -> None: """#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр") — _STREET_KW_RE must match them exactly like the dotted forms.""" from app.services.estimator import extract_street_name assert extract_street_name(address) == expected # ── Helpers ─────────────────────────────────────────────────────────────────── @pytest.fixture() def trade_in_app() -> FastAPI: """Minimal FastAPI app mounting only the trade-in router with DB overridden.""" from app.api.v1 import trade_in as trade_in_module from app.core.db import get_db application = FastAPI() application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") def _override_db(): yield MagicMock() application.dependency_overrides[get_db] = _override_db return application def _make_db_mock(rows) -> MagicMock: """DB session mock returning *rows* from .mappings().all().""" db = MagicMock() mapping_result = MagicMock() mapping_result.all.return_value = rows execute_result = MagicMock() execute_result.mappings.return_value = mapping_result db.execute.return_value = execute_result return db def _make_deal_row( *, address: str = "Екатеринбург, Космонавтов", area_m2: float = 50.0, rooms: int = 2, floor: int | None = 5, total_floors: int | None = 9, price_rub: int = 5_000_000, price_per_m2: int = 100_000, deal_date: date | None = None, source: str = "rosreestr", ) -> dict: return { "address": address, "area_m2": area_m2, "rooms": rooms, "floor": floor, "total_floors": total_floors, "price_rub": price_rub, "price_per_m2": price_per_m2, "deal_date": deal_date or date(2025, 6, 1), "source": source, } # ── Test: empty result when DB returns no rows ──────────────────────────────── def test_street_deals_returns_empty_when_no_match(trade_in_app: FastAPI) -> None: """Endpoint returns count=0 / empty deals list when DB has no matching rows.""" db_mock = _make_db_mock([]) from app.core.db import get_db def _override(): yield db_mock trade_in_app.dependency_overrides[get_db] = _override client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/street-deals", params={ "address": "г. Екатеринбург, ул. Космонавтов, 50", "area_m2": 50.0, "rooms": 2, }, ) assert resp.status_code == 200 data = resp.json() assert data["count"] == 0 assert data["deals"] == [] assert data["street"] == "Космонавтов" assert data["median_price_rub"] == 0 # ── Test: aggregation with fixture deals ───────────────────────────────────── def test_street_deals_aggregates_correctly(trade_in_app: FastAPI) -> None: """With 5 fake deals the endpoint computes correct count + median.""" # price_per_m2 values sorted: [80k, 90k, 100k, 110k, 120k] → median = 100k # median_price_rub = 100k * 50m² = 5_000_000 fixture_rows = [ _make_deal_row(price_rub=6_000_000, price_per_m2=120_000, deal_date=date(2025, 10, 1)), _make_deal_row(price_rub=5_500_000, price_per_m2=110_000, deal_date=date(2025, 9, 1)), _make_deal_row(price_rub=5_000_000, price_per_m2=100_000, deal_date=date(2025, 8, 1)), _make_deal_row(price_rub=4_500_000, price_per_m2=90_000, deal_date=date(2025, 7, 1)), _make_deal_row(price_rub=4_000_000, price_per_m2=80_000, deal_date=date(2025, 6, 1)), ] db_mock = _make_db_mock(fixture_rows) from app.core.db import get_db def _override(): yield db_mock trade_in_app.dependency_overrides[get_db] = _override client = TestClient(trade_in_app) resp = client.get( "/api/v1/trade-in/street-deals", params={ "address": "г. Екатеринбург, ул. Космонавтов, 50", "area_m2": 50.0, "rooms": 2, "period_months": 12, }, ) assert resp.status_code == 200 data = resp.json() assert data["street"] == "Космонавтов" assert data["count"] == 5 assert data["median_price_per_m2"] == 100_000 assert data["median_price_rub"] == 5_000_000 # 100k * 50m² assert data["range_low_rub"] == 4_000_000 assert data["range_high_rub"] == 6_000_000 # All 5 deals returned (top-10 threshold) assert len(data["deals"]) == 5 # DB rows already ordered by deal_date DESC — first is most recent (2025-10-01) assert data["deals"][0]["price_rub"] == 6_000_000 # ── Test: word-boundary street match (Part A1 fix) ─────────────────────────── def test_street_regex_word_boundary_no_false_positive() -> None: """«Мира» must NOT match «улица Адмирала Макарова» (contains 'мира' as substring). We verify the pattern construction directly using Python re (\\b is equivalent to Postgres \\m/\\M for word-boundary checks on these inputs). """ import re street = "Мира" pattern = r"\b" + re.escape(street) + r"\b" # Must NOT match — 'мира' appears inside 'Макарова' substring check skipped # but more critically — 'мира' appears inside 'Адмирала Макарова' false_positive_addr = "улица Адмирала Макарова" assert not re.search(pattern, false_positive_addr, re.IGNORECASE), ( f"Pattern {pattern!r} should NOT match {false_positive_addr!r}" ) # Must match — exact word true_positive_addr = "улица Мира 5" assert re.search(pattern, true_positive_addr, re.IGNORECASE), ( f"Pattern {pattern!r} should match {true_positive_addr!r}" ) def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None: """Endpoint passes street_regex with word-boundary markers to DB execute().""" db_mock = _make_db_mock([]) from app.core.db import get_db def _override(): yield db_mock trade_in_app.dependency_overrides[get_db] = _override client = TestClient(trade_in_app) client.get( "/api/v1/trade-in/street-deals", params={ "address": "г. Екатеринбург, ул. Мира, 10", "area_m2": 50.0, "rooms": 2, }, ) assert db_mock.execute.called, "db.execute should have been called" call_args = db_mock.execute.call_args params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) assert "street_regex" in params, f"street_regex not in params: {params}" regex_val = params["street_regex"] # Must contain word-boundary anchors assert r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m"), ( f"Expected word-boundary in regex, got: {regex_val!r}" ) # ── Test: region-scope (#dkp-corridor-scope 2026-09-12) ─────────────────────── @pytest.mark.parametrize( "address,expected_region_code", [ ("Москва, Тверская улица, 6", 77), ("Екатеринбург, ул. Космонавтов, 50", 66), # Точное совпадение сегмента, НЕ подстрока: "Московская улица" в ЕКБ # содержит "москва" как подстроку, но сегмент целиком ≠ "москва" — не # должна уехать в регион 77 (иначе одноимённая улица ЕКБ подмешалась # бы в московскую выборку, и наоборот). ("Екатеринбург, Московская улица, 12", 66), (None, 66), ("", 66), ], ) def test_region_code_for_address(address: str | None, expected_region_code: int) -> None: from app.services.estimator import region_code_for_address assert region_code_for_address(address) == expected_region_code def test_street_deals_region_code_scoped_for_moscow(trade_in_app: FastAPI) -> None: """Московский адрес: extract_street_name теперь распознаёт улицу, но _resolve_target_city знает только города обл.66 → без region_code фильтра одноимённая улица ЕКБ (region_code=66) подмешалась бы в московскую выборку. Ручка обязана передать region_code=77 в SQL-фильтр.""" db_mock = _make_db_mock([]) from app.core.db import get_db def _override(): yield db_mock trade_in_app.dependency_overrides[get_db] = _override client = TestClient(trade_in_app) client.get( "/api/v1/trade-in/street-deals", params={ "address": "Москва, Тверская улица, 6", "area_m2": 50.0, "rooms": 2, }, ) assert db_mock.execute.called call_args = db_mock.execute.call_args sql_text = str(call_args[0][0]) params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) assert "region_code" in params, f"region_code not passed to SQL: {params}" assert params["region_code"] == 77 assert "region_code" in sql_text, "SQL must filter by region_code" def test_street_deals_region_code_unchanged_for_ekaterinburg(trade_in_app: FastAPI) -> None: """Регресс-гейт: екатеринбургский адрес по-прежнему скоупится в регион 66 (DEFAULT_REGION_CODE) — фильтр не отрезает существующие ЕКБ-результаты.""" db_mock = _make_db_mock([]) from app.core.db import get_db def _override(): yield db_mock trade_in_app.dependency_overrides[get_db] = _override client = TestClient(trade_in_app) client.get( "/api/v1/trade-in/street-deals", params={ "address": "Екатеринбург, ул. Космонавтов, 50", "area_m2": 50.0, "rooms": 2, }, ) assert db_mock.execute.called call_args = db_mock.execute.call_args params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) assert params["region_code"] == 66