"""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_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()