gendesign/tradein-mvp/backend/tests/test_search_api.py
bot-backend b64334d7bd
Some checks failed
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
feat(tradein): segment=vtorichka|novostroyki|all query-param on listings (#1188)
2026-06-17 21:43:01 +03:00

156 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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