p3 naming-consistency (cadastral cols 0% filled). Migration 094_cadastral_unify.sql (idempotent DO-block IF EXISTS): drop+recreate listings_search_mv (MV) + v_data_quality (view), RENAME deals/house_metadata kadastr_num->cadastral_number, DROP listings.kadastr_num + listings_kadastr_idx. Code SQL-string renames in search_query/estimator/base (Lot dataclass field NOT renamed). 240 tests pass. WARN: destructive DDL auto-applies to prod on deploy -> MERGE-GATE post-demo. Reviewer: verify recreated MV/view DDL vs LIVE pg_get_viewdef before apply (reconstructed from migration history 050/046, not live capture). Refs #732
120 lines
3.6 KiB
Python
120 lines
3.6 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,
|
|
has_kadastr=True,
|
|
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 "cadastral_number IS NOT NULL" 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_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_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()
|