gendesign/tradein-mvp/backend/tests/test_search_api.py
bot-backend 3fd6550a16
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m54s
fix(tradein/matching): честность тиров сопоставления домов (#2674)
Три находки эпика #2674 про верхние тиры матчинга домов. Замеры — прод
tradein-postgres, 2026-08-05/06.

Кадастр от площадок не приходит вообще. listings.cadastral_number (кадастр
КВАРТИРЫ) — 0 из 93 408; единственный писатель, парсер Циана, читает
offer["cadastralNumber"], которого в ответе нет. Все 28 504 заполненных
building_cadastral_number на 100% пришли из локального гео-зеркала ЕГРН
(tasks/cadastral_geo_match.py, KNN <=50 м) — проверено джойном к
cad_buildings_local. Поэтому снят фильтр поиска has_kadastr: предикат
`cadastral_number IS NOT NULL` мог вернуть только пустую выдачу. Колонка и
писатель оставлены — заработают сами, если площадка начнёт отдавать кадастр.

Tier 0 cadastr_exact оставлен, но не подключён к гео-кадастру. Он достижим по
построению (ScrapedLot -> адаптер -> матчер), просто данных нет; подать туда
KNN-заполнение НЕЛЬЗЯ: как ключ здания оно не инъективно — 656 из 3 260
значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1
значение (26.2%). Это был бы over-merge с confidence 1.0. Заодно исправлено
ложное утверждение в шапке cadastral_geo_match.py, будто Tier 0 трактует эту
колонку как подсказку.

Tier 0.5 fias_exact удалён из match_or_create_house. Параметра house_fias_id
не было ни в Protocol scraper_kit.contracts.HouseMatcher, ни в
RealMatcherAdapter, ни у двух прямых вызывающих — передать его было некому.
В match_house_readonly тир оставлен: у estimate-пути источник ФИАС есть
(payload.target_fias_id / DaData).

Что чинит сопоставление на самом деле: ключ идентичности в house_dedup_merge
расширен с house_fias_id до COALESCE(house_fias_id, gar_house_guid). Это один
и тот же UUID здания в ГАР (3 666 совпадений из 3 667 домов, где заполнены оба),
но заполняют его разные источники, и половина в проход не входила. Read-only
прогон отрендеренного mapping-SQL на проде: старый ключ — 0 пар, новый — 781
(8.3% таблицы houses, 6 389 объявлений на них). Канон-проход эти дома узнаёт
(900 пар из 919 имеют один канон-адрес), но блокирует гео-стражем: 356 пар
с NULL geom, 457 дальше 250 м (максимум 5 065 км — битый геокод). Ровно
аргумент #2187: общий UUID здания старше близости.

Качество сопоставления сейчас: 0 из 49 502 строк house_sources сматчены
верхними тирами; fingerprint 58.97%, new 22.65%, geo_proximity 18.36%.

Тесты: новый tests/test_matching_tier_reachability_2674.py сверяет параметры
матчера с границей вызова (Protocol + адаптер) — ловит класс «ветка есть,
передать некому», который обычный тест не видит, потому что зовёт функцию
напрямую. Удалены два теста мёртвого fias-тира: они были зелёными ровно
потому, что обходили границу вызова.

Refs #2674
2026-08-06 04:37:12 +05:00

170 lines
6.1 KiB
Python
Raw 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 /api/v1/search — golden path + filters + cache hit/miss."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.schemas.search import SearchParams
from app.services.cache import get_search_cache
from app.services.search_query import build_count_query, build_search_query
def test_build_query_minimal():
sql, args = build_search_query(SearchParams())
assert "FROM listings_search_mv" in sql
assert "WHERE 1=1" in sql
assert "scraped_at DESC" in sql
assert args["limit"] == 50
assert args["offset"] == 0
def test_build_query_geo_radius():
sql, args = build_search_query(
SearchParams(lat=56.838, lon=60.594, radius_m=1500, sort="dist_asc")
)
assert "ST_DWithin" in sql
assert "CAST(:lat AS double precision)" in sql
assert args["lat"] == 56.838
assert args["radius_m"] == 1500
def test_build_query_all_filters():
params = SearchParams(
rooms=2,
area_m2_min=40,
area_m2_max=80,
price_rub_max=10_000_000,
year_built_min=2000,
multi_source_only=True,
require_avito=True,
address_query="Малышева",
)
sql, args = build_search_query(params)
assert "rooms = CAST(:rooms AS integer)" in sql
assert "total_area >= CAST(:area_min AS double precision)" in sql
assert "price_rub <= CAST(:price_max AS bigint)" in sql
assert "year_built >= CAST(:yb_min AS integer)" in sql
assert "source_count >= 2" in sql
assert "has_avito = true" in sql
assert "address ILIKE" in sql
assert args["addr_like"] == "%Малышева%"
def test_no_unsatisfiable_cadastral_filter() -> None:
"""#2674: фильтр по кадастру снят — он мог вернуть только пустую выдачу.
`listings.cadastral_number` (кадастр КВАРТИРЫ) пуст у всех 93 408 объявлений на проде:
площадки его не отдают. Предикат `cadastral_number IS NOT NULL` в WHERE обещал
пользователю фильтр качества данных, которого не существует. Ни один набор параметров
поиска не должен уметь его собрать. RED до фикса: has_kadastr=True вставлял предикат.
"""
for params in (
SearchParams(),
SearchParams(**{"has_kadastr": True}), # старый клиент — параметр игнорируется
):
sql, _ = build_search_query(params)
assert "cadastral_number IS NOT NULL" not in sql
def test_build_query_sources_array():
sql, args = build_search_query(SearchParams(sources=["avito", "cian"]))
assert "sources && CAST(:sources AS text[])" in sql
assert args["sources"] == ["avito", "cian"]
def test_build_query_segment_default_vtorichka():
"""Без параметра segment — back-compat: canon-предикат вторички (#1186/#1188)."""
sql, _ = build_search_query(SearchParams())
assert SearchParams().segment == "vtorichka"
assert "listing_id IN (SELECT id FROM listings WHERE" in sql
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql
def test_build_query_segment_novostroyki():
"""segment=novostroyki — только первичка, без NULL-вторички."""
sql, _ = build_search_query(SearchParams(segment="novostroyki"))
assert "listing_segment = 'novostroyki'" in sql
# Не должен попасть canon-guard вторички.
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" not in sql
def test_build_query_segment_all_no_filter():
"""segment=all — без фильтрации по сегменту."""
sql, _ = build_search_query(SearchParams(segment="all"))
assert "listing_segment" not in sql
def test_build_count_query_inherits_segment():
"""COUNT-запрос наследует segment-фильтр из build_search_query."""
sql, _ = build_count_query(SearchParams(segment="novostroyki"))
assert "listing_segment = 'novostroyki'" in sql
sql_v, _ = build_count_query(SearchParams())
assert "(listing_segment IS NULL OR listing_segment = 'vtorichka')" in sql_v
def test_segment_invalid_rejected():
"""Невалидное значение segment отклоняется валидацией."""
with pytest.raises(ValueError):
SearchParams(segment="kommercia")
def test_build_count_query_strips_limit():
sql, args = build_count_query(SearchParams(rooms=2, page=3))
assert "count(*)" in sql
assert "ORDER BY" not in sql
assert "LIMIT" not in sql
assert "offset" not in args
assert "limit" not in args
def test_geo_pair_required():
with pytest.raises(ValueError, match="lat and lon"):
SearchParams(lat=56.0)
def test_dist_sort_requires_geo():
with pytest.raises(ValueError, match="dist_asc"):
SearchParams(sort="dist_asc")
def test_price_range_consistency():
with pytest.raises(ValueError, match="price_rub_min > price_rub_max"):
SearchParams(price_rub_min=10_000_000, price_rub_max=5_000_000)
def test_page_size_capped():
with pytest.raises(ValueError):
SearchParams(page_size=500)
@pytest.fixture(autouse=True)
def _reset_cache_singleton(monkeypatch):
get_search_cache.cache_clear()
mock = AsyncMock()
mock.get = AsyncMock(return_value=None)
mock.set = AsyncMock()
mock.search_key = lambda p: "tradein:search:test"
mock.TTL_SEARCH = 300
monkeypatch.setattr("app.api.v1.search.get_search_cache", lambda: mock)
return mock
def test_search_cache_hit(_reset_cache_singleton):
cached_payload = {
"items": [],
"total": 0,
"page": 1,
"page_size": 50,
"elapsed_ms": 0.0,
"cache_hit": True,
}
_reset_cache_singleton.get = AsyncMock(return_value=cached_payload)
client = TestClient(app)
r = client.post("/api/v1/search", json={"rooms": 2})
assert r.status_code == 200
assert r.json()["cache_hit"] is True
_reset_cache_singleton.set.assert_not_awaited()