Skip to content

Commit e218386

Browse files
committed
2 parents 4990e8a + e53d95a commit e218386

File tree

13 files changed

+726
-0
lines changed

13 files changed

+726
-0
lines changed

backend/requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pytest==9.0.2
2+
pytest-asyncio==1.3.0
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import pytest
2+
from adapters.output.json_parser_adapter import JSONParserAdapter
3+
from domain.models import ResultStatus, ResultCode
4+
5+
@pytest.fixture
6+
def parser():
7+
return JSONParserAdapter()
8+
9+
def test_parse_perfect_json(parser):
10+
"""Testa il caso ideale: JSON pulito"""
11+
raw = '{"outcome": {"status": "success", "code": "OK"}, "data": {"rewritten_text": "Ciao"}}'
12+
result = parser.parse_response(raw)
13+
14+
assert result.status == ResultStatus.SUCCESS
15+
assert result.rewritten_text == "Ciao"
16+
17+
def test_parse_markdown_json(parser):
18+
"""Testa il caso comune: LLM risponde dentro blocchi Markdown"""
19+
raw = """
20+
Ecco il risultato:
21+
```json
22+
{
23+
"outcome": {"status": "success", "code": "OK"},
24+
"data": {"rewritten_text": "Testo pulito"}
25+
}
26+
```
27+
Spero sia utile.
28+
"""
29+
result = parser.parse_response(raw)
30+
31+
assert result.status == ResultStatus.SUCCESS
32+
assert result.rewritten_text == "Testo pulito"
33+
34+
def test_parse_invalid_json_fallback(parser):
35+
"""Verifica che il parser gestisca errori di sintassi JSON restituendo un LLMResult di errore"""
36+
raw = "Questa non è una stringa JSON"
37+
result = parser.parse_response(raw)
38+
39+
assert result.status == ResultStatus.ERROR
40+
assert result.code == ResultCode.TECHNICAL_ERROR
41+
assert "Parse error" in result.violation_category
42+
43+
def test_parse_malformed_enum_values(parser):
44+
"""Verifica il fallback se l'LLM inventa stati o codici non esistenti"""
45+
raw = '{"outcome": {"status": "inventato", "code": "BOOOH"}}'
46+
result = parser.parse_response(raw)
47+
48+
# Grazie ai tuoi try-except interni, deve fare il fallback sui default
49+
assert result.status == ResultStatus.ERROR
50+
assert result.code == ResultCode.TECHNICAL_ERROR
51+
52+
def test_parse_with_special_characters(parser):
53+
"""Testa la sanitizzazione di newline e tabulazioni (logica interna dell'adapter)"""
54+
raw = '{"outcome": {"status": "success"}, "data": {"rewritten_text": "Riga 1\nRiga 2"}}'
55+
result = parser.parse_response(raw)
56+
57+
assert result.status == ResultStatus.SUCCESS
58+
assert "Riga 1" in result.rewritten_text
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import pytest
2+
import json
3+
from unittest.mock import AsyncMock, patch, MagicMock
4+
from adapters.output.llm_client_adapter import LLMClientAdapter
5+
6+
@pytest.fixture
7+
def providers():
8+
return [
9+
{"name": "Primary", "url": "http://primary.ai", "model": "gpt-1"},
10+
{"name": "Fallback", "url": "http://fallback.ai", "model": "gpt-2"}
11+
]
12+
13+
@pytest.fixture
14+
def adapter(providers):
15+
return LLMClientAdapter(providers)
16+
17+
@pytest.mark.asyncio
18+
async def test_generate_completion_success_first_try(adapter):
19+
"""Verifica che se il primo provider funziona, restituisca la risposta corretta"""
20+
# Mock _call_api_stream
21+
async def mock_stream(*args, **kwargs):
22+
yield "Ciao "
23+
yield "mondo"
24+
25+
with patch.object(LLMClientAdapter, '_call_api_stream', side_effect=mock_stream):
26+
result = await adapter.generate_completion([{"role": "user", "content": "hi"}])
27+
28+
assert result == "Ciao mondo"
29+
30+
@pytest.mark.asyncio
31+
async def test_generate_completion_fallback_logic(adapter):
32+
"""Verifica che se il primo fallisce, provi il secondo"""
33+
call_count = 0
34+
35+
async def mock_stream_with_fallback(*args, **kwargs):
36+
nonlocal call_count
37+
call_count += 1
38+
if call_count == 1:
39+
raise Exception("Primary Down")
40+
yield "Risposta da fallback"
41+
42+
with patch.object(LLMClientAdapter, '_call_api_stream', side_effect=mock_stream_with_fallback):
43+
result = await adapter.generate_completion([{"role": "user", "content": "hi"}])
44+
45+
assert result == "Risposta da fallback"
46+
assert call_count == 2 # Conferma che ha provato entrambi
47+
48+
@pytest.mark.asyncio
49+
async def test_all_providers_fail_raises_exception(adapter):
50+
"""Verifica che se tutti i provider falliscono, venga lanciata un'eccezione"""
51+
with patch.object(LLMClientAdapter, '_call_api_stream', side_effect=Exception("Network Error")):
52+
with pytest.raises(Exception) as excinfo:
53+
await adapter.generate_completion([{"role": "user", "content": "hi"}])
54+
55+
assert "Nessun servizio AI disponibile" in str(excinfo.value)
56+
57+
@pytest.mark.asyncio
58+
async def test_validate_connection_always_true(adapter):
59+
"""Verifica il metodo obbligatorio dal contratto"""
60+
assert await adapter.validate_connection() is True
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import pytest
2+
from adapters.output.prompt_builder_adapter import PromptBuilderAdapter
3+
from domain.models import TextDocument
4+
5+
@pytest.fixture
6+
def builder():
7+
return PromptBuilderAdapter()
8+
9+
def test_build_summarize_prompt_contains_variables(builder):
10+
"""Verifica che il prompt di riassunto includa la percentuale e il testo"""
11+
doc = TextDocument(content="Questo è un testo di prova.")
12+
percentage = 50
13+
14+
messages = builder.build_summarize_prompt(doc, percentage)
15+
16+
# Verifichiamo la struttura (System + User)
17+
assert len(messages) == 2
18+
assert messages[0]["role"] == "system"
19+
assert messages[1]["role"] == "user"
20+
21+
# Verifichiamo che i parametri siano stati iniettati
22+
user_content = messages[1]["content"]
23+
assert str(percentage) in user_content
24+
assert "Questo è un testo di prova." in user_content
25+
# Verifica protezione XML
26+
assert "<text_to_process>" in user_content
27+
28+
def test_build_six_hats_specific_instruction(builder):
29+
"""Verifica che il cappello 'nero' carichi le istruzioni di cautela"""
30+
doc = TextDocument(content="Idea di business")
31+
32+
messages = builder.build_six_hats_prompt(doc, "nero")
33+
system_content = messages[0]["content"]
34+
35+
assert "CAPPELLO NERO" in system_content.upper()
36+
assert "avvocato del diavolo" in system_content.lower()
37+
38+
def test_build_generate_prompt_with_context(builder):
39+
"""Verifica che la generazione includa il contesto se fornito"""
40+
prompt = "Scrivi un articolo"
41+
context = "Usa uno stile amichevole"
42+
43+
messages = builder.build_generate_prompt(prompt, context_text=context, word_count=100)
44+
user_content = messages[1]["content"]
45+
46+
assert "<context>" in user_content
47+
assert context in user_content
48+
assert "100" in messages[0]["content"] # Word count nel system prompt
49+
50+
def test_build_translate_target_language(builder):
51+
"""Verifica che la lingua di destinazione sia corretta nel prompt"""
52+
doc = TextDocument(content="Hello")
53+
target = "Italiano"
54+
55+
messages = builder.build_translate_prompt(doc, target)
56+
57+
assert target in messages[1]["content"]
58+
assert "motore di traduzione AI" in messages[0]["content"]
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, MagicMock
3+
from application.use_cases.analyze_six_hats import AnalyzeSixHatsUseCase
4+
from domain.models import TextDocument, LLMResult, ResultStatus, ResultCode
5+
6+
@pytest.fixture
7+
def mocks():
8+
return {
9+
"llm": AsyncMock(),
10+
"builder": MagicMock(),
11+
"parser": MagicMock()
12+
}
13+
14+
@pytest.fixture
15+
def use_case(mocks):
16+
return AnalyzeSixHatsUseCase(
17+
llm_provider=mocks["llm"],
18+
prompt_builder=mocks["builder"],
19+
response_parser=mocks["parser"]
20+
)
21+
22+
@pytest.mark.asyncio
23+
async def test_six_hats_invalid_hat_name(use_case):
24+
"""Verifica che un cappello con nome errato (es. 'viola') venga rifiutato"""
25+
doc = TextDocument(content="Testo valido")
26+
result = await use_case.execute(doc, hat="viola")
27+
28+
assert result.status == ResultStatus.INVALID_INPUT
29+
assert "non supportato" in result.violation_category
30+
31+
@pytest.mark.asyncio
32+
async def test_six_hats_case_insensitivity(use_case, mocks):
33+
"""Verifica che 'ROSSO', 'Rosso' e 'rosso' siano tutti accettati"""
34+
doc = TextDocument(content="Testo")
35+
mocks["builder"].build_six_hats_prompt.return_value = ["prompt"]
36+
mocks["llm"].generate_completion.return_value = "raw"
37+
mocks["parser"].parse_response.return_value = LLMResult(status=ResultStatus.SUCCESS, code=ResultCode.OK)
38+
39+
# Test con maiuscole
40+
result = await use_case.execute(doc, hat="ROSSO")
41+
42+
assert result.is_successful()
43+
# Verifichiamo che il builder sia stato chiamato (la logica passa la validazione)
44+
mocks["builder"].build_six_hats_prompt.assert_called_once()
45+
46+
@pytest.mark.asyncio
47+
async def test_six_hats_empty_hat_returns_invalid(use_case):
48+
"""Verifica che un cappello None o vuoto venga rifiutato"""
49+
doc = TextDocument(content="Testo")
50+
result = await use_case.execute(doc, hat="")
51+
52+
assert result.status == ResultStatus.INVALID_INPUT
53+
assert "non supportato" in result.violation_category
54+
55+
@pytest.mark.asyncio
56+
async def test_six_hats_success_flow(use_case, mocks):
57+
"""Verifica il successo con un cappello valido (es. 'verde')"""
58+
doc = TextDocument(content="Analisi creativa")
59+
hat = "verde"
60+
61+
mocks["builder"].build_six_hats_prompt.return_value = [{"role": "system", "content": "verde"}]
62+
mocks["llm"].generate_completion.return_value = "Risposta creativa"
63+
expected = LLMResult(status=ResultStatus.SUCCESS, code=ResultCode.OK, rewritten_text="Analisi verde")
64+
mocks["parser"].parse_response.return_value = expected
65+
66+
result = await use_case.execute(doc, hat=hat)
67+
68+
mocks["builder"].build_six_hats_prompt.assert_called_with(doc, hat)
69+
assert result == expected
70+
71+
@pytest.mark.asyncio
72+
async def test_six_hats_llm_exception(use_case, mocks):
73+
"""Verifica la gestione dell'errore se il provider LLM fallisce"""
74+
doc = TextDocument(content="Testo")
75+
mocks["builder"].build_six_hats_prompt.return_value = ["prompt"]
76+
mocks["llm"].generate_completion.side_effect = Exception("LLM Down")
77+
78+
result = await use_case.execute(doc, hat="nero")
79+
80+
assert result.status == ResultStatus.ERROR
81+
assert "LLM Down" in result.violation_category
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, MagicMock
3+
from application.use_cases.generate_text import GenerateTextUseCase
4+
from domain.models import LLMResult, ResultStatus, ResultCode
5+
6+
@pytest.fixture
7+
def mocks():
8+
return {
9+
"llm": AsyncMock(),
10+
"builder": MagicMock(),
11+
"parser": MagicMock()
12+
}
13+
14+
@pytest.fixture
15+
def use_case(mocks):
16+
return GenerateTextUseCase(
17+
llm_provider=mocks["llm"],
18+
prompt_builder=mocks["builder"],
19+
response_parser=mocks["parser"]
20+
)
21+
22+
@pytest.mark.asyncio
23+
async def test_generate_empty_prompt_returns_invalid(use_case):
24+
"""Verifica che un prompt vuoto o composto da soli spazi venga rifiutato"""
25+
result = await use_case.execute(prompt=" ")
26+
27+
assert result.status == ResultStatus.INVALID_INPUT
28+
assert result.code == ResultCode.EMPTY_PROMPT
29+
assert "Il prompt non può essere vuoto" in result.violation_category
30+
31+
@pytest.mark.asyncio
32+
async def test_generate_success_with_context(use_case, mocks):
33+
"""Verifica il flusso con prompt, contesto e word count personalizzato"""
34+
# ARRANGE
35+
prompt = "Scrivi una mail"
36+
context = "L'utente è un manager"
37+
words = 150
38+
39+
mocks["builder"].build_generate_prompt.return_value = [{"role": "user", "content": "..."}]
40+
mocks["llm"].generate_completion.return_value = "Risposta AI"
41+
expected = LLMResult(status=ResultStatus.SUCCESS, code=ResultCode.OK, rewritten_text="Mail generata")
42+
mocks["parser"].parse_response.return_value = expected
43+
44+
# ACT
45+
result = await use_case.execute(prompt=prompt, context_text=context, word_count=words)
46+
47+
# ASSERT
48+
# Verifichiamo che il builder riceva tutti i parametri corretti
49+
mocks["builder"].build_generate_prompt.assert_called_once_with(prompt, context, words)
50+
assert result == expected
51+
52+
@pytest.mark.asyncio
53+
async def test_generate_uses_default_values(use_case, mocks):
54+
"""Verifica che vengano usati i valori di default per contesto e word_count"""
55+
prompt = "Genera un'idea"
56+
57+
await use_case.execute(prompt=prompt)
58+
59+
# Il word_count di default nel tuo codice è 300
60+
mocks["builder"].build_generate_prompt.assert_called_once_with(prompt, "", 300)
61+
62+
@pytest.mark.asyncio
63+
async def test_generate_handles_llm_exception(use_case, mocks):
64+
"""Verifica la resilienza in caso di crash del provider LLM"""
65+
mocks["builder"].build_generate_prompt.return_value = ["prompt"]
66+
mocks["llm"].generate_completion.side_effect = Exception("Quota API esaurita")
67+
68+
result = await use_case.execute(prompt="Test")
69+
70+
assert result.status == ResultStatus.ERROR
71+
assert result.code == ResultCode.TECHNICAL_ERROR
72+
assert "Quota API esaurita" in result.violation_category

0 commit comments

Comments
 (0)