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
`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 чистый.
488 lines
21 KiB
Python
488 lines
21 KiB
Python
"""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
|