All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 24s
Deploy Trade-In / build-backend (push) Successful in 40s
Deploy Trade-In / deploy (push) Successful in 35s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
302 lines
11 KiB
Python
302 lines
11 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
|
||
|
||
|
||
# ── 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}"
|