API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1# ============================================2# Projet : API-KA3# Fichier : tests/test_search.py4# Node : m3u96b5# Author : Simon-Pierre Boucher6# Contact : contact@spboucher.ai7# Date : 2026-08-238# ============================================9"""Tests des routes /api/v1/search et /api/v1/suggest (proxy Trouve·Ka).1011Le moteur amont est mocké (monkeypatch de ``search._get``) : aucun appel12réseau réel pendant la suite de tests.13"""1415from __future__ import annotations1617from typing import Any1819import httpx20import pytest21from fastapi.testclient import TestClient2223from src.api.main import app24from src.api.routes import search as search_module2526FAKE_SEARCH = {27 "query": "montreal",28 "total": 42,29 "took_ms": 12,30 "page": 1,31 "limit": 3,32 "semantic": True,33 "reranked": True,34 "related": ["hôtel montreal"],35 "results": [36 {37 "title": "Montréal",38 "url": "https://example.com/montreal",39 "display_url": "example.com › montreal",40 "snippet": "La métropole du Québec.",41 "domain": "example.com",42 "language": "fr",43 "quebec_score": 0.9,44 "badges": [],45 "published_at": None,46 "image": None,47 }48 ],49}5051FAKE_SUGGEST = {"suggestions": ["logement montreal", "logement quebec"]}525354@pytest.fixture(scope="module")55def client():56 """Client de test FastAPI avec cycle de vie (lifespan) actif."""57 with TestClient(app) as test_client:58 yield test_client596061def _mock_upstream(monkeypatch: pytest.MonkeyPatch, captured: dict[str, Any]) -> None:62 """Remplace le GET amont par un faux moteur qui capture url + params."""6364 async def fake_get(url: str, params: dict[str, Any]) -> Any:65 captured["url"] = url66 captured["params"] = params67 if "suggest" in url:68 return FAKE_SUGGEST69 return FAKE_SEARCH7071 monkeypatch.setattr(search_module, "_get", fake_get)727374def test_search_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:75 """GET /api/v1/search proxifie le moteur et renvoie l'enveloppe uniforme."""76 captured: dict[str, Any] = {}77 _mock_upstream(monkeypatch, captured)78 response = client.get("/api/v1/search", params={"q": "montreal", "limit": 3})79 assert response.status_code == 20080 body = response.json()81 assert body["success"] is True82 assert body["data"] == FAKE_SEARCH["results"]83 assert body["meta"]["total"] == 4284 assert body["meta"]["page"] == 185 assert body["meta"]["limit"] == 386 assert body["meta"]["query"] == "montreal"87 assert body["meta"]["took_ms"] == 1288 assert body["meta"]["related"] == ["hôtel montreal"]89 assert body["meta"]["source"] == "trouve-ka"90 assert captured["params"] == {"q": "montreal", "page": 1, "limit": 3}919293def test_search_forwards_optional_filters(94 client: TestClient, monkeypatch: pytest.MonkeyPatch95) -> None:96 """Les filtres optionnels (language, site, category, freshness) sont transmis."""97 captured: dict[str, Any] = {}98 _mock_upstream(monkeypatch, captured)99 response = client.get(100 "/api/v1/search",101 params={102 "q": "logement",103 "language": "fr",104 "site": "www.lou-ka.com",105 "category": "immobilier",106 "freshness": "week",107 },108 )109 assert response.status_code == 200110 assert captured["params"]["language"] == "fr"111 assert captured["params"]["site"] == "www.lou-ka.com"112 assert captured["params"]["category"] == "immobilier"113 assert captured["params"]["freshness"] == "week"114115116def test_search_validation(client: TestClient) -> None:117 """q est requis, limit plafonné à 50, freshness/language contraints."""118 assert client.get("/api/v1/search").status_code == 422119 assert client.get("/api/v1/search", params={"q": ""}).status_code == 422120 assert (121 client.get("/api/v1/search", params={"q": "x", "limit": 51}).status_code == 422122 )123 assert (124 client.get("/api/v1/search", params={"q": "x", "language": "es"}).status_code125 == 422126 )127 assert (128 client.get("/api/v1/search", params={"q": "x", "freshness": "hour"}).status_code129 == 422130 )131132133def test_search_upstream_error_is_502(134 client: TestClient, monkeypatch: pytest.MonkeyPatch135) -> None:136 """Une erreur httpx du moteur amont devient un 502 explicite."""137138 async def broken(url: str, params: dict[str, Any]) -> Any:139 raise httpx.ConnectError("moteur injoignable")140141 monkeypatch.setattr(search_module, "_get", broken)142 response = client.get("/api/v1/search", params={"q": "montreal"})143 assert response.status_code == 502144 assert "Trouve·Ka" in response.json()["detail"]145146147def test_suggest_envelope(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:148 """GET /api/v1/suggest renvoie les suggestions dans l'enveloppe uniforme."""149 captured: dict[str, Any] = {}150 _mock_upstream(monkeypatch, captured)151 response = client.get("/api/v1/suggest", params={"q": "logem"})152 assert response.status_code == 200153 body = response.json()154 assert body["success"] is True155 assert body["data"] == FAKE_SUGGEST["suggestions"]156 assert body["meta"]["source"] == "trouve-ka"157 assert captured["params"] == {"q": "logem"}158159160def test_suggest_min_length(client: TestClient) -> None:161 """q exige au moins 2 caractères."""162 assert client.get("/api/v1/suggest", params={"q": "l"}).status_code == 422163164165def test_search_not_captured_by_service_catchall(client: TestClient) -> None:166 """/api/v1/search ne doit PAS tomber dans le catch-all /api/v1/{service}.167168 Sans paramètres, la route search répond 422 (q requis) ; si le catch-all169 l'attrapait, on obtiendrait le 404 « Service inconnu : search ».170 """171 response = client.get("/api/v1/search")172 assert response.status_code == 422173 response = client.get("/api/v1/notaservice")174 assert response.status_code == 404175