# ============================================ # Projet : API-KA # Fichier : tests/test_search.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-23 # ============================================ """Tests des routes /api/v1/search et /api/v1/suggest (proxy Trouve·Ka). Le moteur amont est mocké (monkeypatch de ``search._get``) : aucun appel réseau réel pendant la suite de tests. """ from __future__ import annotations from typing import Any import httpx import pytest from fastapi.testclient import TestClient from src.api.main import app from src.api.routes import search as search_module FAKE_SEARCH = { "query": "montreal", "total": 42, "took_ms": 12, "page": 1, "limit": 3, "semantic": True, "reranked": True, "related": ["hôtel montreal"], "results": [ { "title": "Montréal", "url": "https://example.com/montreal", "display_url": "example.com › montreal", "snippet": "La métropole du Québec.", "domain": "example.com", "language": "fr", "quebec_score": 0.9, "badges": [], "published_at": None, "image": None, } ], } FAKE_SUGGEST = {"suggestions": ["logement montreal", "logement quebec"]} @pytest.fixture(scope="module") def client(): """Client de test FastAPI avec cycle de vie (lifespan) actif.""" with TestClient(app) as test_client: yield test_client def _mock_upstream(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> None: """Remplace le GET amont par un faux moteur qui capture url + params.""" async def fake_get(url: str, params: dict[str, Any]) -> Any: captured["url"] = url captured["params"] = params if "suggest" in url: return FAKE_SUGGEST return FAKE_SEARCH monkeypatch.setattr(search_module, "_get", fake_get) def test_search_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /api/v1/search proxifie le moteur et renvoie l'enveloppe uniforme.""" captured: dict[str, Any] = {} _mock_upstream(monkeypatch, captured) response = client.get("/api/v1/search", params={"q": "montreal", "limit": 3}) assert response.status_code == 200 body = response.json() assert body["success"] is True assert body["data"] == FAKE_SEARCH["results"] assert body["meta"]["total"] == 42 assert body["meta"]["page"] == 1 assert body["meta"]["limit"] == 3 assert body["meta"]["query"] == "montreal" assert body["meta"]["took_ms"] == 12 assert body["meta"]["related"] == ["hôtel montreal"] assert body["meta"]["source"] == "trouve-ka" assert captured["params"] == {"q": "montreal", "page": 1, "limit": 3} def test_search_forwards_optional_filters( client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """Les filtres optionnels (language, site, category, freshness) sont transmis.""" captured: dict[str, Any] = {} _mock_upstream(monkeypatch, captured) response = client.get( "/api/v1/search", params={ "q": "logement", "language": "fr", "site": "www.lou-ka.com", "category": "immobilier", "freshness": "week", }, ) assert response.status_code == 200 assert captured["params"]["language"] == "fr" assert captured["params"]["site"] == "www.lou-ka.com" assert captured["params"]["category"] == "immobilier" assert captured["params"]["freshness"] == "week" def test_search_validation(client: TestClient) -> None: """q est requis, limit plafonné à 50, freshness/language contraints.""" assert client.get("/api/v1/search").status_code == 422 assert client.get("/api/v1/search", params={"q": ""}).status_code == 422 assert ( client.get("/api/v1/search", params={"q": "x", "limit": 51}).status_code == 422 ) assert ( client.get("/api/v1/search", params={"q": "x", "language": "es"}).status_code == 422 ) assert ( client.get("/api/v1/search", params={"q": "x", "freshness": "hour"}).status_code == 422 ) def test_search_upstream_error_is_502( client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """Une erreur httpx du moteur amont devient un 502 explicite.""" async def broken(url: str, params: dict[str, Any]) -> Any: raise httpx.ConnectError("moteur injoignable") monkeypatch.setattr(search_module, "_get", broken) response = client.get("/api/v1/search", params={"q": "montreal"}) assert response.status_code == 502 assert "Trouve·Ka" in response.json()["detail"] def test_suggest_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /api/v1/suggest renvoie les suggestions dans l'enveloppe uniforme.""" captured: dict[str, Any] = {} _mock_upstream(monkeypatch, captured) response = client.get("/api/v1/suggest", params={"q": "logem"}) assert response.status_code == 200 body = response.json() assert body["success"] is True assert body["data"] == FAKE_SUGGEST["suggestions"] assert body["meta"]["source"] == "trouve-ka" assert captured["params"] == {"q": "logem"} def test_suggest_min_length(client: TestClient) -> None: """q exige au moins 2 caractères.""" assert client.get("/api/v1/suggest", params={"q": "l"}).status_code == 422 def test_search_not_captured_by_service_catchall(client: TestClient) -> None: """/api/v1/search ne doit PAS tomber dans le catch-all /api/v1/{service}. Sans paramètres, la route search répond 422 (q requis) ; si le catch-all l'attrapait, on obtiendrait le 404 « Service inconnu : search ». """ response = client.get("/api/v1/search") assert response.status_code == 422 response = client.get("/api/v1/notaservice") assert response.status_code == 404