gendesign/tradein-mvp/backend/tests/test_street_deals_endpoint.py
bot-backend d78b1f8881
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m5s
fix(tradein): ДКП-коридор по Москве не строился — имя улицы не извлекалось
`extract_street_name` возвращал None для любого московского адреса, потому
что парсер ждёт тип улицы ПЕРЕД названием («ул. Малышева»), а в Москве он
стоит после: «Тверская улица, 6». Keyword-регекс требует пробел сразу за
типом, там запятая — совпадения нет вовсе; дальше fallback брал первый
токен с большой буквы, получал «Москва» из стоп-списка и отдавал None.

Следствие на проде (замер 12.09): оценка по московскому адресу отвечает
200 с 25 аналогами, но `dkp_corridor` в ответе — null, при 212 937
московских ДКП в базе. Коридор сделок по Москве не строился ни разу.

Добавлен второй проход: ищем тип улицы без требования пробела и берём
1-3 слова ДО него в пределах той же запятой-секции. Прежний путь не
тронут — «ул. X» и реверс-формат Nominatim разбираются как раньше;
непустые результаты не меняются, новый проход даёт значение только там,
где раньше был None. Списки типов улиц вынесены в общую константу, чтобы
два регекса не разъехались при добавлении нового типа.

Нумерованные проезды («Проектируемый проезд № 4062») намеренно остаются
None: имя «Проектируемый» собрало бы коридор по сотням разных проездов.

## Регион-скоуп двух ручек

Непустое имя улицы включает `/street-deals` и `/sales-vs-listings`, где
раньше для Москвы был ранний выход. Обе скоупятся только по
`_resolve_target_city` — словарю городов Свердловской области, — поэтому
для Москвы фильтр города пуст, и остаётся один ILIKE по улице.

Замер на проде: улица «Ясная» — 168 сделок в регионе 66 и 80 в 77,
«Советская» — 1202 и 17. Без фильтра региона московский запрос смешал бы
екатеринбургские сделки в медиану, то есть фикс парсера сам по себе
открыл бы дыру. Поэтому в обе ручки добавлен обязательный фильтр по
`region_code`; регион выводится из адреса через реестр регионов точным
сравнением сегмента, а не подстрокой — иначе екатеринбургская
«Московская улица» уехала бы в регион 77.

В `deals` регион заполнен у всех строк (66 → 108 623, 77 → 212 937,
NULL нет), так что фильтр ничего не отрезает у существующих запросов.

У `/sales-vs-listings` табличная функция параметра региона не знает, её
миграция в этот фикс не входит. Фильтр применён снаружи, соединением с
`deals` по идентификатору сделки: сторона объявлений остаётся без
регион-скоупа. Это осознанный компромисс, он описан в коде; полный фикс
— отдельная миграция с параметром региона внутри функции.

Тесты: 535 passed во всех файлах, затрагивающих коридор и уличную
статистику (+13 новых), ruff чистый.
2026-09-12 13:43:56 +03:00

488 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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