fix(ai): risposta AI malformata -> 502, non 400 (#36 punto 7)

json.JSONDecodeError e' un ValueError: KeyError/JSONDecodeError sulla
risposta del modello finivano nell'except ValueError del router (pensato
per i nostri errori di configurazione, es. API key mancante) e uscivano
come 400 invece di 502. Nuova AIResponseError dedicata in ai_service.py
per contenuto malformato o JSON non valido; recipes.py la traduce in 502,
toccato solo per questo except.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:43:42 +02:00
parent 65f6a4b18b
commit 403c923188
4 changed files with 127 additions and 2 deletions
+67
View File
@@ -0,0 +1,67 @@
"""Tests for ai_service.py error handling (gitea#36 point 7).
A malformed response from the AI model (missing fields, invalid JSON) must
raise the dedicated AIResponseError, not a bare KeyError/JSONDecodeError —
JSONDecodeError in particular is itself a ValueError subclass, so without
this it was caught by the router's `except ValueError` (meant for our own
config errors) and misreported as a 400 instead of a 502.
"""
import json
from unittest.mock import AsyncMock, patch
import pytest
from src.backend.services import ai_service
class _FakeResponse:
def __init__(self, json_data):
self._json_data = json_data
def raise_for_status(self):
pass
def json(self):
return self._json_data
def _patch_openrouter(monkeypatch, response_json: dict):
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: "some extracted text")
monkeypatch.setattr(ai_service.settings, "openrouter_api_key", "test-key")
monkeypatch.setattr(ai_service.settings, "openrouter_model", "test-model")
fake_client = AsyncMock()
fake_client.post = AsyncMock(return_value=_FakeResponse(response_json))
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
fake_client.__aexit__ = AsyncMock(return_value=False)
return patch("src.backend.services.ai_service.httpx.AsyncClient", return_value=fake_client)
async def test_missing_choices_key_raises_ai_response_error(monkeypatch):
with _patch_openrouter(monkeypatch, {"no_choices_here": True}):
with pytest.raises(ai_service.AIResponseError):
await ai_service.parse_technical_sheet(b"%PDF-fake")
async def test_invalid_json_content_raises_ai_response_error(monkeypatch):
bad_content = "not valid json {{{"
response_json = {"choices": [{"message": {"content": bad_content}}]}
with _patch_openrouter(monkeypatch, response_json):
with pytest.raises(ai_service.AIResponseError):
await ai_service.parse_technical_sheet(b"%PDF-fake")
async def test_valid_json_content_returns_tasks(monkeypatch):
tasks = [{"title": "MATERIALI", "directive": "Verificare", "description": "..."}]
response_json = {"choices": [{"message": {"content": json.dumps(tasks)}}]}
with _patch_openrouter(monkeypatch, response_json):
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
assert result == tasks
async def test_empty_extracted_text_returns_empty_list(monkeypatch):
"""No AI call at all when the PDF has no extractable text."""
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: " ")
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
assert result == []
@@ -0,0 +1,33 @@
"""Router-level test for gitea#36 point 7: recipes.py translates
ai_service.AIResponseError to 502, not the config-error 400.
A dedicated file (not test_recipes.py) to avoid colliding with concurrent
work on the copy-on-write recipe logic in that shared test module.
"""
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
from src.backend.services import ai_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
async def test_ai_response_error_is_502(
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
monkeypatch,
):
recipe = await create_test_recipe(db_session, maker_user.id, code="AI-PARSE-502")
async def _raise_ai_response_error(_pdf_bytes):
raise ai_service.AIResponseError("Risposta del modello AI non e' JSON valido: boom")
monkeypatch.setattr(ai_service, "parse_technical_sheet", _raise_ai_response_error)
resp = await client.post(
f"/api/recipes/{recipe.id}/parse-technical-sheet",
headers=auth_headers(maker_user),
files={"file": ("sheet.pdf", b"%PDF-1.4 fake", "application/pdf")},
)
assert resp.status_code == 502