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
Три находки эпика #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
170 lines
6.1 KiB
Python
170 lines
6.1 KiB
Python
"""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()
|