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