Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s

Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
This commit is contained in:
2026-06-18 15:59:10 +02:00
parent eb78a75621
commit 4d049274f9
68 changed files with 4433 additions and 1360 deletions

View File

@@ -0,0 +1,204 @@
"""Tests de caractérisation des adapters LLM « OpenAI-compatible »
(OpenRouter, Gemini, Mistral).
But : VERROUILLER le comportement observable AVANT d'extraire une classe de base
commune (les trois adapters partageaient ~80 % de code). On couvre via respx
(mock du transport httpx) : collecte du stream, payload envoyé, en-têtes, parsing
SSE, et traduction des erreurs HTTP — sans aucun appel réseau réel.
Ces tests doivent rester verts à l'identique après le refactor.
"""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
def _settings(**kw) -> Settings:
return Settings(_env_file=None, llm_timeout_seconds=30, **kw)
def _sse(*contents: str) -> str:
"""Construit un corps SSE OpenAI : une trame `data: {choices:[{delta:{content}}]}`
par fragment, terminé par `data: [DONE]`."""
lines: list[str] = []
for c in contents:
lines.append("data: " + json.dumps({"choices": [{"delta": {"content": c}}]}))
lines.append("")
lines += ["data: [DONE]", ""]
return "\n".join(lines)
# (id, classe, url, kwargs settings (clé+modèle), supporte response_format=json_object)
CASES = [
pytest.param(
OpenRouterLLMProvider,
"https://openrouter.ai/api/v1/chat/completions",
dict(openrouter_api_key="k", openrouter_model="m"),
False,
"OpenRouter",
id="openrouter",
),
pytest.param(
GeminiLLMProvider,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
dict(gemini_api_key="k", gemini_model="m"),
True,
"Gemini",
id="gemini",
),
pytest.param(
MistralLLMProvider,
"https://api.mistral.ai/v1/chat/completions",
dict(mistral_api_key="k", mistral_model="m"),
True,
"Mistral",
id="mistral",
),
]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_generate_collects_full_stream(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("Bonjour", " le", " monde")))
svc = cls(_settings(**skw))
assert await svc.generate("salut") == "Bonjour le monde"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_stream_chat_yields_tokens(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("A", "B", "C")))
svc = cls(_settings(**skw))
tokens = [t async for t in svc.stream_chat([ChatMessage(role="user", content="hi")])]
assert tokens == ["A", "B", "C"]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_system_prompt_and_temperature(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
_ = [t async for t in svc.stream_chat(
[ChatMessage(role="user", content="Q")],
system_prompt="SYS",
temperature=0.5,
)]
body = json.loads(route.calls.last.request.content)
assert body["model"] == "m"
assert body["stream"] is True
assert body["messages"][0] == {"role": "system", "content": "SYS"}
assert body["messages"][-1] == {"role": "user", "content": "Q"}
assert body["temperature"] == 0.5
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_omits_temperature_when_none(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
body = json.loads(route.calls.last.request.content)
assert "temperature" not in body
# Sans system_prompt, generate envoie un unique message user.
assert body["messages"] == [{"role": "user", "content": "p"}]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_response_format_json_only_when_supported(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("{}")))
svc = cls(_settings(**skw))
await svc.generate("p", output_format="json")
body = json.loads(route.calls.last.request.content)
if supports_json:
assert body["response_format"] == {"type": "json_object"}
else:
assert "response_format" not in body
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_authorization_header_bearer(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
assert route.calls.last.request.headers["Authorization"] == "Bearer k"
@respx.mock
async def test_openrouter_attribution_headers():
route = respx.post("https://openrouter.ai/api/v1/chat/completions").mock(
return_value=httpx.Response(200, text=_sse("x")))
svc = OpenRouterLLMProvider(_settings(openrouter_api_key="k", openrouter_model="m"))
await svc.generate("p")
headers = route.calls.last.request.headers
assert headers["HTTP-Referer"] == "https://loremind.app"
assert headers["X-Title"] == "LoreMind"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_http_error_translated_to_provider_error(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(429, text="quota exceeded"))
svc = cls(_settings(**skw))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
msg = str(exc.value)
assert label in msg
assert "429" in msg
assert "quota exceeded" in msg
@respx.mock
async def test_gemini_rejected_key_gives_actionable_message():
respx.post(
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
).mock(return_value=httpx.Response(403, text="API key not valid"))
svc = GeminiLLMProvider(_settings(gemini_api_key="k", gemini_model="m"))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
assert "refusée par Google" in str(exc.value)
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_sse_skips_keepalive_and_malformed_lines(cls, url, skw, supports_json, label):
body = "\n".join([
": OPENROUTER PROCESSING", # commentaire keep-alive
"",
"data: not-json", # JSON invalide -> ignoré
"",
"data: " + json.dumps({"choices": []}), # pas de choix -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {}}]}), # delta sans content -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {"content": "OK"}}]}),
"",
"data: [DONE]",
"",
])
respx.post(url).mock(return_value=httpx.Response(200, text=body))
svc = cls(_settings(**skw))
assert await svc.generate("p") == "OK"
@pytest.mark.parametrize("cls, skw", [
pytest.param(OpenRouterLLMProvider, dict(openrouter_api_key=""), id="openrouter"),
pytest.param(GeminiLLMProvider, dict(gemini_api_key=""), id="gemini"),
pytest.param(MistralLLMProvider, dict(mistral_api_key=""), id="mistral"),
])
def test_missing_api_key_raises_at_construction(cls, skw):
with pytest.raises(LLMProviderError):
cls(_settings(**skw))