gendesign/tradein-mvp/backend/tests/test_street_deals_endpoint.py
lekss361 9d9457f67d
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m30s
Deploy Trade-In / test (push) Successful in 3m14s
Deploy Trade-In / build-backend (push) Successful in 7m24s
Deploy Trade-In / deploy (push) Successful in 1m25s
fix(tradein/estimate): не блокировать оценку — расширять подбор и честно предупреждать (#2823)
2026-08-10 15:42:22 +00:00

360 lines
14 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-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}"