jurix-api / tests /test_api /test_classifier_routes.py
rahim237cm's picture
Claude Opus 5.5
fix(reclassement): la simulation montre tout ce que --force ecrira
f508b99
Raw History Blame Contribute Delete
6.01 kB
"""
Tests de l'API de classification.
L'ancienne route `GET /api/v1/classifier/categories` renvoyait un dictionnaire
code en dur qui contredisait `GET /api/v1/categories`, servi depuis la base.
Deux listes de categories concurrentes sur la meme API garantissaient qu'un
client se fie a la mauvaise ; ces tests figent sa disparition.
`POST /classify` coute une requete Groq : elle est reservee aux
administrateurs, et dit 503 (avec Retry-After) quand le modele ne peut pas
repondre, au lieu d'inventer un domaine. Le classifieur est double : ces
tests portent sur la route, pas sur le modele.
Usage:
pytest tests/test_api/test_classifier_routes.py -v
"""
import pytest
from httpx import AsyncClient
from app.main import app
from app.services.legal_domain_classifier import (
CANONICAL_DOMAINS,
FINANCES,
FONCTION_PUBLIQUE,
ClassementIndisponible,
DomainResult,
get_legal_domain_classifier,
)
URL = "/api/v1/classifier/classify"
class _Classeur:
"""Doublure : rend `domaine`, ou leve `erreur`."""
def __init__(self, domaine=FINANCES, erreur=None):
self.domaine = domaine
self.erreur = erreur
self.titres = []
async def classify_async(self, title, content="", doc_type=None):
self.titres.append(title)
if self.erreur:
raise self.erreur
return DomainResult(
domain=self.domaine, confidence=0.9, rule="groq:doublure", source="groq",
runners_up=((FONCTION_PUBLIQUE, 0.72),),
)
def health_check(self):
return {"status": "healthy", "domains": len(CANONICAL_DOMAINS)}
@pytest.fixture
def classeur():
"""Installe la doublure derriere la dependance de la route."""
doublure = _Classeur()
app.dependency_overrides[get_legal_domain_classifier] = lambda: doublure
try:
yield doublure
finally:
app.dependency_overrides.pop(get_legal_domain_classifier, None)
class TestAcces:
async def test_anonyme_refuse(self, client: AsyncClient, classeur):
response = await client.post(URL, json={"title": "Loi de finances"})
assert response.status_code == 401
assert classeur.titres == []
async def test_utilisateur_sans_role_refuse(self, client: AsyncClient, auth_headers, classeur):
response = await client.post(URL, json={"title": "Loi de finances"}, headers=auth_headers)
assert response.status_code == 403
assert classeur.titres == []
class TestClassify:
async def test_domaine_et_identifiant_resolu_en_base(
self, admin_client: AsyncClient, db_session, classeur
):
"""
L'identifiant rendu doit designer, en base, la ligne portant ce nom.
C'est precisement ce que l'ancien code ne faisait pas : il rendait une
position dans un dictionnaire Python.
"""
from sqlalchemy import select
from app.models.law import Category
response = await admin_client.post(
URL, json={"title": "Loi N°2015/019 portant Loi de finances pour l'exercice 2016"}
)
assert response.status_code == 200
body = response.json()
assert body["domain"] == FINANCES
assert body["rule"] == "groq:doublure"
assert body["runners_up"] == [{"domain": FONCTION_PUBLIQUE, "score": 0.72}]
name = (await db_session.execute(
select(Category.name).where(Category.id == body["category_id"])
)).scalar_one()
assert name == body["domain"]
async def test_requete_vide_refusee(self, admin_client: AsyncClient, classeur):
response = await admin_client.post(URL, json={"title": "", "text": " "})
assert response.status_code == 422
assert classeur.titres == []
async def test_texte_reduit_aux_visas_rend_422(self, admin_client: AsyncClient, classeur):
"""Une erreur de la demande, qu'un nouvel essai ne corrigera pas : pas de 503."""
from app.services.legal_domain_classifier import DocumentVide
classeur.erreur = DocumentVide("document vide")
response = await admin_client.post(
URL, json={"title": "", "text": "Vu la Constitution ;\nVu le décret n°2011/408 ;"}
)
assert response.status_code == 422
async def test_classement_indisponible_rend_503(self, admin_client: AsyncClient, classeur):
"""Jamais de domaine invente : l'ancien code rendait Droit Administratif."""
classeur.erreur = ClassementIndisponible("quota Groq", retry_after=41.2, quota=True)
response = await admin_client.post(URL, json={"title": "Loi de finances"})
assert response.status_code == 503
assert response.headers["retry-after"] == "42"
async def test_503_sans_delai_connu(self, admin_client: AsyncClient, classeur):
classeur.erreur = ClassementIndisponible("panne")
response = await admin_client.post(URL, json={"title": "Loi de finances"})
assert response.status_code == 503
assert "retry-after" not in response.headers
class TestRemovedEndpoint:
async def test_hardcoded_categories_endpoint_is_gone(self, client: AsyncClient, db_session):
response = await client.get("/api/v1/classifier/categories")
assert response.status_code == 404
async def test_categories_come_from_the_database(self, client: AsyncClient, db_session):
"""La seule liste de categories servie par l'API est celle de la base."""
response = await client.get("/api/v1/categories")
assert response.status_code == 200
names = {row["name"] for row in response.json()}
assert names == set(CANONICAL_DOMAINS)
class TestHealth:
async def test_health_lists_the_canonical_domains(self, client: AsyncClient):
response = await client.get("/api/v1/classifier/health")
assert response.status_code == 200
body = response.json()
assert body["status"] == "healthy"
assert body["canonical_domains"] == list(CANONICAL_DOMAINS)