gendesign/tradein-mvp/backend/tests/test_search_api.py
lekss361 7814e1223a feat(tradein): Phase 3.2 — /api/v1/search endpoint + Redis cache
Multi-Source Integration Phase 3.2. /api/v1/search with cross-source filters
+ POST body params + Redis 5min hot cache.

New files:
  app/schemas/search.py            — SearchParams (~20 filters, Pydantic v2)
  app/schemas/search_response.py   — SearchResponse + SearchResultItem
  app/services/search_query.py     — build_search_query (psycopg v3, CAST(:x AS type))
  app/services/cache.py            — SearchCache async wrapper (singleton lru_cache)
  app/api/v1/search.py             — POST /search endpoint
  tests/test_search_api.py         — unit + integration tests

Edited:
  app/main.py                      — include search.router prefix=/api/v1
  app/core/config.py               — settings.redis_url default
  pyproject.toml                   — add redis>=5.0.0

Queries listings_search_mv (matview from PR #469). Cache failures swallowed
(degrade to slow-but-correct). Sort whitelisted dict (no SQL injection).

Refs Master Plan sec 9.1 + 7.2.
2026-05-23 17:38:00 +03:00

110 lines
3.5 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 "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()