mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 13:54:20 +00:00
first commit
This commit is contained in:
BIN
tests_original_develop/.DS_Store
vendored
Normal file
BIN
tests_original_develop/.DS_Store
vendored
Normal file
Binary file not shown.
1
tests_original_develop/__init__.py
Normal file
1
tests_original_develop/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for Python Agent Boilerplate."""
|
||||
37
tests_original_develop/conftest.py
Normal file
37
tests_original_develop/conftest.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Pytest configuration and fixtures for all tests.
|
||||
"""
|
||||
|
||||
# Stub google.cloud.pubsub_v1 BEFORE any other import. `agent_framework`
|
||||
# (private TIM package) instantiates a PublisherClient at import time,
|
||||
# which requires GCloud creds; tests run without those.
|
||||
import sys as _sys
|
||||
from unittest.mock import MagicMock as _MagicMock
|
||||
_sys.modules.setdefault("google.cloud.pubsub_v1", _MagicMock())
|
||||
|
||||
# Load .env BEFORE any other import so that Settings fields that call
|
||||
# os.environ[] at class-definition time (e.g. TAIS_DB_DSN) are satisfied.
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def setup_test_environment():
|
||||
"""
|
||||
Set up environment variables for testing.
|
||||
|
||||
This fixture runs once per test session and sets required
|
||||
environment variables to allow config module to load.
|
||||
"""
|
||||
# Set required environment variables for Settings
|
||||
os.environ.setdefault("LLM_PROVIDER", "openai")
|
||||
os.environ.setdefault("LLM_MODEL", "gpt-4")
|
||||
os.environ.setdefault("LOG_LEVEL", "INFO")
|
||||
os.environ.setdefault("LOG_FORMAT", "text")
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup is not needed as these are just defaults
|
||||
1
tests_original_develop/fixtures/__init__.py
Normal file
1
tests_original_develop/fixtures/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test fixtures for the agent boilerplate."""
|
||||
155
tests_original_develop/fixtures/mock_llm.py
Normal file
155
tests_original_develop/fixtures/mock_llm.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Mock LLM implementations for testing without API calls."""
|
||||
|
||||
from typing import List, Optional, Any
|
||||
from langchain_core.messages import AIMessage, BaseMessage
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
import pytest
|
||||
|
||||
|
||||
class MockLLM(BaseChatModel):
|
||||
"""
|
||||
Mock LLM for testing without making actual API calls.
|
||||
|
||||
This mock can be configured with predefined responses and tracks
|
||||
the number of times it's been called.
|
||||
|
||||
Example:
|
||||
>>> mock = MockLLM(responses=["Hello!", "How can I help?"])
|
||||
>>> response = await mock.ainvoke([HumanMessage(content="Hi")])
|
||||
>>> assert response.content == "Hello!"
|
||||
"""
|
||||
|
||||
responses: List[str]
|
||||
call_count: int = 0
|
||||
|
||||
def __init__(self, responses: Optional[List[str]] = None, **kwargs):
|
||||
"""
|
||||
Initialize mock LLM with predefined responses.
|
||||
|
||||
Args:
|
||||
responses: List of responses to return in sequence.
|
||||
If None, returns "Mock response" for all calls.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.responses = responses or ["Mock response"]
|
||||
self.call_count = 0
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
"""Return identifier for this LLM type."""
|
||||
return "mock"
|
||||
|
||||
def _generate(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
stop: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
"""Generate a mock response synchronously."""
|
||||
response = self.responses[self.call_count % len(self.responses)]
|
||||
self.call_count += 1
|
||||
|
||||
message = AIMessage(content=response)
|
||||
generation = ChatGeneration(message=message)
|
||||
|
||||
return ChatResult(generations=[generation])
|
||||
|
||||
async def _agenerate(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
stop: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
"""Generate a mock response asynchronously."""
|
||||
return self._generate(messages, stop, **kwargs)
|
||||
|
||||
def reset(self):
|
||||
"""Reset call count for reuse in tests."""
|
||||
self.call_count = 0
|
||||
|
||||
|
||||
class MockLLMWithError(BaseChatModel):
|
||||
"""
|
||||
Mock LLM that raises errors for testing error handling.
|
||||
|
||||
Example:
|
||||
>>> mock = MockLLMWithError(error_message="API timeout")
|
||||
>>> with pytest.raises(Exception):
|
||||
... await mock.ainvoke([HumanMessage(content="Hi")])
|
||||
"""
|
||||
|
||||
error_message: str
|
||||
|
||||
def __init__(self, error_message: str = "Mock LLM error", **kwargs):
|
||||
"""
|
||||
Initialize mock LLM that raises errors.
|
||||
|
||||
Args:
|
||||
error_message: Error message to raise
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.error_message = error_message
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
"""Return identifier for this LLM type."""
|
||||
return "mock_error"
|
||||
|
||||
def _generate(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
stop: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
"""Raise an error."""
|
||||
raise Exception(self.error_message)
|
||||
|
||||
async def _agenerate(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
stop: Optional[List[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
"""Raise an error asynchronously."""
|
||||
raise Exception(self.error_message)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm():
|
||||
"""
|
||||
Pytest fixture providing a basic mock LLM.
|
||||
|
||||
Returns:
|
||||
MockLLM instance with default response
|
||||
"""
|
||||
return MockLLM()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_with_responses():
|
||||
"""
|
||||
Pytest fixture factory for creating mock LLM with custom responses.
|
||||
|
||||
Returns:
|
||||
Function that creates MockLLM with specified responses
|
||||
|
||||
Example:
|
||||
def test_conversation(mock_llm_with_responses):
|
||||
llm = mock_llm_with_responses(["Hi!", "Goodbye!"])
|
||||
# Use llm in test
|
||||
"""
|
||||
def _create_mock(responses: List[str]) -> MockLLM:
|
||||
return MockLLM(responses=responses)
|
||||
return _create_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_with_error():
|
||||
"""
|
||||
Pytest fixture providing a mock LLM that raises errors.
|
||||
|
||||
Returns:
|
||||
MockLLMWithError instance
|
||||
"""
|
||||
return MockLLMWithError()
|
||||
1
tests_original_develop/integration/__init__.py
Normal file
1
tests_original_develop/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Integration tests."""
|
||||
1
tests_original_develop/property/__init__.py
Normal file
1
tests_original_develop/property/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Property-based tests using Hypothesis."""
|
||||
1
tests_original_develop/unit/__init__.py
Normal file
1
tests_original_develop/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests."""
|
||||
141
tests_original_develop/unit/test_anatel_validation.py
Normal file
141
tests_original_develop/unit/test_anatel_validation.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import pytest
|
||||
from src.api.schemas.anatel_schemas import (
|
||||
TicketRequestEvent, ReasonCode, ERROR_CODE_MAPPING,
|
||||
Customer, Address, Subscriber, Complaint, Origin, SubmittedBy
|
||||
)
|
||||
from src.agent.logic.validation import validate_required_fields
|
||||
from datetime import datetime
|
||||
|
||||
# ---- Fixture compartilhada ----------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def valid_ticket_payload():
|
||||
"""Retorna um payload compatível com TicketRequestEvent (Swagger v0.0.5)"""
|
||||
return {
|
||||
"caseType": "anatel",
|
||||
"origin": {
|
||||
"sourceSystem": "test",
|
||||
"submittedBy": {"userId": "u1", "name": "n1"}
|
||||
},
|
||||
"ticketId": "DS-98765",
|
||||
"transactionId": "trans-123",
|
||||
"customer": {
|
||||
"cpfCnpj": "12345678901",
|
||||
"phones": ["+5511999999999"],
|
||||
"msisdn": "5511999999999",
|
||||
"name": "Cliente Exemplo",
|
||||
"odcCustomer": False,
|
||||
"contumazCustomer": False,
|
||||
"address": {
|
||||
"cep": "01001-000",
|
||||
"street": "Praca da Se",
|
||||
"neighborhood": "Se",
|
||||
"city": "Sao Paulo",
|
||||
"state": "SP"
|
||||
},
|
||||
"subscriber": {
|
||||
"cpfCnpj": "12345678901",
|
||||
"subscriberName": "Parente do Cliente"
|
||||
}
|
||||
},
|
||||
"complaint": {
|
||||
"complaintProtocol": "202603270001",
|
||||
"actionType": "nova",
|
||||
"inputChannel": "Anatel",
|
||||
"service": "S",
|
||||
"firstService": "FS",
|
||||
"modality": "M",
|
||||
"motive": "M",
|
||||
"description": "Descricao da reclamacao",
|
||||
"openedAt": datetime.now()
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Testes de caminho feliz (happy path) --------------------------
|
||||
|
||||
def test_validate_valid_ticket(valid_ticket_payload):
|
||||
"""Valida um ticket 100% preenchido conforme o novo contrato."""
|
||||
ticket = TicketRequestEvent(**valid_ticket_payload)
|
||||
result = validate_required_fields(ticket)
|
||||
assert result.is_valid is True
|
||||
|
||||
# ---- Testes de cobertura total de ReasonCodes (Exaustivo) -----------
|
||||
|
||||
@pytest.mark.parametrize("loc_tuple, mapping_val", ERROR_CODE_MAPPING.items())
|
||||
def test_exhaustive_reason_code_coverage(valid_ticket_payload, loc_tuple, mapping_val):
|
||||
"""
|
||||
Testa cada entrada do ERROR_CODE_MAPPING removendo o campo correspondente
|
||||
e verificando se o validate_required_fields retorna o ReasonCode e a mensagem esperados.
|
||||
"""
|
||||
reason_code, expected_text = mapping_val
|
||||
|
||||
# Navega no dicionário para remover o campo
|
||||
data_ptr = valid_ticket_payload
|
||||
for part in loc_tuple[:-1]:
|
||||
data_ptr = data_ptr[part]
|
||||
|
||||
# Remove ou limpa o campo
|
||||
field_to_clear = loc_tuple[-1]
|
||||
data_ptr[field_to_clear] = "" if isinstance(data_ptr[field_to_clear], str) else None
|
||||
|
||||
# Reconstrói os modelos aninhados usando model_construct para evitar o Pydantic
|
||||
# mas permitir acesso por atributo (.) na lógica de validação manual
|
||||
address = Address.model_construct(**valid_ticket_payload["customer"]["address"])
|
||||
|
||||
cust_data = valid_ticket_payload["customer"].copy()
|
||||
cust_data.pop("address", None)
|
||||
cust_data.pop("subscriber", None)
|
||||
|
||||
sub_data = valid_ticket_payload["customer"]["subscriber"].copy()
|
||||
subscriber = Subscriber.model_construct(**sub_data)
|
||||
|
||||
customer = Customer.model_construct(
|
||||
address=address,
|
||||
subscriber=subscriber,
|
||||
**cust_data
|
||||
)
|
||||
|
||||
comp_data = valid_ticket_payload["complaint"].copy()
|
||||
complaint = Complaint.model_construct(**comp_data)
|
||||
|
||||
orig_data = valid_ticket_payload["origin"].copy()
|
||||
sub_by_data = orig_data.pop("submittedBy", {})
|
||||
sub_by = SubmittedBy.model_construct(**sub_by_data)
|
||||
origin = Origin.model_construct(
|
||||
submittedBy=sub_by,
|
||||
**orig_data
|
||||
)
|
||||
|
||||
top_data = valid_ticket_payload.copy()
|
||||
top_data.pop("customer", None)
|
||||
top_data.pop("complaint", None)
|
||||
top_data.pop("origin", None)
|
||||
|
||||
ticket = TicketRequestEvent.model_construct(
|
||||
customer=customer,
|
||||
complaint=complaint,
|
||||
origin=origin,
|
||||
**top_data
|
||||
)
|
||||
|
||||
result = validate_required_fields(ticket)
|
||||
|
||||
assert result.is_valid is False
|
||||
codes = [m["code"] for m in result.error_response["detail"]["messages"]]
|
||||
texts = [m["text"] for m in result.error_response["detail"]["messages"]]
|
||||
|
||||
assert reason_code.value in codes
|
||||
assert expected_text in texts
|
||||
|
||||
def test_validate_error_response_format(valid_ticket_payload):
|
||||
"""Verifica se o formato do erro segue o padrão Swagger (title, status, detail)."""
|
||||
valid_ticket_payload["complaint"]["motive"] = ""
|
||||
ticket = TicketRequestEvent(**valid_ticket_payload)
|
||||
result = validate_required_fields(ticket)
|
||||
assert result.is_valid is False
|
||||
# Verificando formato fixo exigido pela TIM/Swagger
|
||||
assert result.error_response["title"] == "validation error"
|
||||
assert result.error_response["status"] == 400
|
||||
assert "detail" in result.error_response
|
||||
assert "messages" in result.error_response["detail"]
|
||||
assert isinstance(result.error_response["detail"]["messages"], list)
|
||||
461
tests_original_develop/unit/test_canceling_analysis_duplicate.py
Normal file
461
tests_original_develop/unit/test_canceling_analysis_duplicate.py
Normal file
@@ -0,0 +1,461 @@
|
||||
"""Unit tests for the duplicate-detection branch in `canceling_analysis_node`."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent.nodes.canceling_analysis_node import (
|
||||
DuplicateAnalysisError,
|
||||
_analyze_duplicate,
|
||||
perform_canceling_analysis,
|
||||
)
|
||||
from src.api.schemas.anatel_schemas import (
|
||||
ComplaintHistoryItem,
|
||||
TicketRequestEvent,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_payload():
|
||||
return {
|
||||
"caseType": "anatel",
|
||||
"origin": {"sourceSystem": "test", "submittedBy": {"userId": "u1", "name": "n1"}},
|
||||
"ticketId": "DS-1",
|
||||
"transactionId": "trx-1",
|
||||
"customer": {
|
||||
"cpfCnpj": "12345678901",
|
||||
"phones": ["+5511988887777"],
|
||||
"msisdn": "5511988887777",
|
||||
"name": "Cliente Teste",
|
||||
"odcCustomer": False,
|
||||
"contumazCustomer": False,
|
||||
"address": {
|
||||
"cep": "01001-000",
|
||||
"street": "Rua",
|
||||
"neighborhood": "Bairro",
|
||||
"city": "Cidade",
|
||||
"state": "SP",
|
||||
},
|
||||
"subscriber": {"cpfCnpj": "12345678901", "subscriberName": "Cliente Teste"},
|
||||
},
|
||||
"complaint": {
|
||||
"complaintProtocol": "202603270001",
|
||||
"actionType": "nova",
|
||||
"inputChannel": "Anatel",
|
||||
"service": "Celular Pós-pago",
|
||||
"firstService": "Celular Pós-pago",
|
||||
"modality": "Cobrança",
|
||||
"motive": "Cobrança indevida",
|
||||
"description": "Reclamação atual descrição completa",
|
||||
"openedAt": "2026-02-15T10:30:00-03:00",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_schema_complaint_history_defaults_to_none(base_payload):
|
||||
"""Payload without complaintHistory loads as None; node normalizes to []."""
|
||||
ticket = TicketRequestEvent(**base_payload)
|
||||
assert ticket.complaintHistory is None
|
||||
|
||||
|
||||
def test_schema_accepts_complaint_history(base_payload):
|
||||
"""ComplaintHistoryItem accepts the contract specified by business."""
|
||||
base_payload["complaintHistory"] = [
|
||||
{
|
||||
"complaintProtocol": "202601150001234",
|
||||
"status": "A Cancelar",
|
||||
"actionType": "nova",
|
||||
"providerProtocol": None,
|
||||
"openedAt": "2026-01-15T10:30:00-03:00",
|
||||
"inputChannel": "Anatel",
|
||||
"service": "Celular Pós-pago",
|
||||
"firstService": "Celular Pós-pago",
|
||||
"modality": "Cobrança",
|
||||
"motive": "Cobrança após cancelamento",
|
||||
"description": "Cliente reclamou de cobrança indevida em janeiro.",
|
||||
"cpfCnpj": "124526359752",
|
||||
"msisdn": "551198887777",
|
||||
"phones": ["+55 (11) 98888-7777"],
|
||||
}
|
||||
]
|
||||
ticket = TicketRequestEvent(**base_payload)
|
||||
assert len(ticket.complaintHistory) == 1
|
||||
item = ticket.complaintHistory[0]
|
||||
assert isinstance(item, ComplaintHistoryItem)
|
||||
assert item.complaintProtocol == "202601150001234"
|
||||
assert item.phones == ["+55 (11) 98888-7777"]
|
||||
|
||||
|
||||
def _llm_response(payload: dict) -> SimpleNamespace:
|
||||
"""Build a stub LLMResponse-shaped object.
|
||||
|
||||
Mirrors the contract of `LLMResponse` after `chat_llm_with_usage(..., expect_json=True)`:
|
||||
`content` carries the raw text and `parsed_json` carries the already-decoded dict.
|
||||
"""
|
||||
return SimpleNamespace(
|
||||
content=json.dumps(payload),
|
||||
usage={"input": 1, "output": 1, "total": 2},
|
||||
parsed_json=payload,
|
||||
)
|
||||
|
||||
|
||||
def _state_with(history=None, complaint_overrides=None):
|
||||
"""Minimal AgentState for the canceling_analysis node."""
|
||||
history = history if history is not None else []
|
||||
complaint = {
|
||||
"service": "Celular Pós-pago",
|
||||
"firstService": "Celular Pós-pago",
|
||||
"modality": "Cobrança",
|
||||
"motive": "Cobrança indevida",
|
||||
"description": "Cliente reclama de cobrança indevida no mês de fevereiro.",
|
||||
"complaintProtocol": "202602150001",
|
||||
"openedAt": "2026-02-15T10:30:00-03:00",
|
||||
}
|
||||
if complaint_overrides:
|
||||
complaint.update(complaint_overrides)
|
||||
|
||||
customer = {
|
||||
"cpfCnpj": "12345678901",
|
||||
"msisdn": "5511988887777",
|
||||
"phones": ["+5511988887777"],
|
||||
}
|
||||
request_context = {
|
||||
"complaint": complaint,
|
||||
"customer": customer,
|
||||
"complaintHistory": history,
|
||||
}
|
||||
return {
|
||||
"messages": [],
|
||||
"current_step": "knowledge_base_enrichment_unavailable",
|
||||
"iteration_count": 0,
|
||||
"tool_results": None,
|
||||
"final_response": None,
|
||||
"session_id": "sess-test",
|
||||
"metadata": {"request_context": request_context},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offensive_short_circuits_duplicate_check():
|
||||
"""When the integrity check decides 'cancelar', duplicate analysis must NOT run."""
|
||||
state = _state_with(history=[{"complaintProtocol": "X", "openedAt": "2026-02-10"}])
|
||||
|
||||
canceling_response = _llm_response({
|
||||
"decision": "cancelar",
|
||||
"reasoning": "Conteúdo ofensivo detectado.",
|
||||
"cancel_reason": "Conteúdo Ofensivo",
|
||||
})
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
return_value=canceling_response,
|
||||
) as mock_llm:
|
||||
result = await perform_canceling_analysis(state)
|
||||
|
||||
# Only the canceling LLM was called — duplicate analysis was skipped.
|
||||
assert mock_llm.call_count == 1
|
||||
ctx = result["metadata"]["request_context"]
|
||||
assert ctx["siebel_action"] == "cancelar"
|
||||
assert ctx["cancel_reason"] == "Conteúdo Ofensivo"
|
||||
assert ctx.get("duplicate_match") is None
|
||||
assert result["current_step"] == "canceling_analysis_cancel_ticket"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_history_short_circuits_to_nova():
|
||||
"""Empty complaintHistory skips the duplicate LLM call (deterministic 'nova')."""
|
||||
state = _state_with(history=[])
|
||||
|
||||
cancel_resp = _llm_response({
|
||||
"decision": "continuar",
|
||||
"reasoning": "OK",
|
||||
"cancel_reason": None,
|
||||
})
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
side_effect=[cancel_resp],
|
||||
) as mock_llm:
|
||||
result = await perform_canceling_analysis(state)
|
||||
|
||||
# Only the canceling LLM was called — duplicate detection short-circuited.
|
||||
assert mock_llm.call_count == 1
|
||||
ctx = result["metadata"]["request_context"]
|
||||
assert ctx.get("siebel_action") is None
|
||||
assert result["current_step"] == "proceed_graph"
|
||||
canceling_decision = ctx["canceling_decision"]
|
||||
assert canceling_decision["decision"] == "continuar"
|
||||
assert "duplicate_match" not in canceling_decision
|
||||
assert "duplicity_analysis" not in canceling_decision
|
||||
# No history: reasoning carries only the integrity-check negative.
|
||||
assert canceling_decision["reasoning"] == "OK"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuar_then_nova_with_non_empty_history_proceeds():
|
||||
"""Both checks pass on non-empty history: LLM runs, no cancellation."""
|
||||
history = [{
|
||||
"complaintProtocol": "202512010001",
|
||||
"status": "Encerrada",
|
||||
"actionType": "nova",
|
||||
"openedAt": "2025-12-01T10:30:00-03:00",
|
||||
"service": "Celular Pós-pago",
|
||||
"modality": "Cobrança",
|
||||
"motive": "Outro motivo",
|
||||
"description": "Reclamação antiga sobre tema distinto.",
|
||||
"cpfCnpj": "12345678901",
|
||||
"msisdn": "5511988887777",
|
||||
"phones": ["+5511988887777"],
|
||||
}]
|
||||
state = _state_with(history=history)
|
||||
|
||||
cancel_resp = _llm_response({
|
||||
"decision": "continuar",
|
||||
"reasoning": "Sem violação de política.",
|
||||
"cancel_reason": None,
|
||||
})
|
||||
duplicate_resp = _llm_response({
|
||||
"decision": "nova",
|
||||
"reasoning": "Histórico com 1 item; pontuação total insuficiente.",
|
||||
})
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
side_effect=[cancel_resp, duplicate_resp],
|
||||
) as mock_llm:
|
||||
result = await perform_canceling_analysis(state)
|
||||
|
||||
assert mock_llm.call_count == 2
|
||||
ctx = result["metadata"]["request_context"]
|
||||
assert ctx.get("siebel_action") is None
|
||||
assert result["current_step"] == "proceed_graph"
|
||||
canceling_decision = ctx["canceling_decision"]
|
||||
assert canceling_decision["decision"] == "continuar"
|
||||
assert "duplicate_match" not in canceling_decision
|
||||
assert "duplicity_analysis" not in canceling_decision
|
||||
# History + nova: reasoning joins integrity-check and duplicate-check negatives.
|
||||
assert canceling_decision["reasoning"] == (
|
||||
"Sem violação de política. Histórico com 1 item; pontuação total insuficiente."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuar_then_duplicada_cancels():
|
||||
"""Duplicate detected: triplet applied, cancel_reason='Duplicidade', match recorded."""
|
||||
history = [{
|
||||
"complaintProtocol": "202601150001234",
|
||||
"status": "Aberta",
|
||||
"actionType": "nova",
|
||||
"providerProtocol": None,
|
||||
"openedAt": "2026-02-01T10:30:00-03:00",
|
||||
"inputChannel": "anatel",
|
||||
"service": "Celular Pós-pago",
|
||||
"firstService": "Celular Pós-pago",
|
||||
"modality": "Cobrança",
|
||||
"motive": "Cobrança indevida",
|
||||
"description": "Cliente reclama de cobrança indevida.",
|
||||
"cpfCnpj": "12345678901",
|
||||
"msisdn": "5511988887777",
|
||||
"phones": ["+5511988887777"],
|
||||
}]
|
||||
state = _state_with(history=history)
|
||||
|
||||
cancel_resp = _llm_response({
|
||||
"decision": "continuar",
|
||||
"reasoning": "Sem violação.",
|
||||
"cancel_reason": None,
|
||||
})
|
||||
duplicate_resp = _llm_response({
|
||||
"decision": "duplicada",
|
||||
"reasoning": (
|
||||
"Candidato sobrevivente com mesmo cliente e mesmo telefone reclamado "
|
||||
"dentro da janela de 30 dias; pontuação total 92."
|
||||
),
|
||||
"case_response": (
|
||||
"Solicito o cancelamento desta ID, pois existe reclamação idêntica "
|
||||
"no protocolo 202601150001234, com mesmo cliente, mesmo número "
|
||||
"reclamado e mesmo motivo."
|
||||
),
|
||||
"duplicate_match": {
|
||||
"matched_protocol": "202601150001234",
|
||||
"similarity_percent": 92,
|
||||
},
|
||||
})
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
side_effect=[cancel_resp, duplicate_resp],
|
||||
) as mock_llm:
|
||||
result = await perform_canceling_analysis(state)
|
||||
|
||||
assert mock_llm.call_count == 2
|
||||
ctx = result["metadata"]["request_context"]
|
||||
assert ctx["siebel_action"] == "cancelar"
|
||||
assert ctx["cancel_reason"] == "Duplicidade"
|
||||
# Duplicate telemetry is nested inside canceling_decision (not top-level).
|
||||
canceling_decision = ctx["canceling_decision"]
|
||||
assert canceling_decision["decision"] == "cancelar"
|
||||
assert canceling_decision["cancel_reason"] == "Duplicidade"
|
||||
# reasoning now carries the model's analytical text (replaces the removed `duplicity_analysis`).
|
||||
assert "duplicity_analysis" not in canceling_decision
|
||||
assert canceling_decision["reasoning"].startswith("Candidato sobrevivente")
|
||||
assert canceling_decision["duplicate_match"]["matched_protocol"] == "202601150001234"
|
||||
assert canceling_decision["duplicate_match"]["similarity_percent"] == 92
|
||||
# Triplet applied
|
||||
assert ctx["reason1"] == "Processo Interno"
|
||||
assert ctx["reason2"] == "Atendimento"
|
||||
assert ctx["reason3"] == "Cancelamento ID Anatel"
|
||||
# Reasoning truncated and persisted
|
||||
assert ctx["canceling_reasoning"].startswith("Candidato sobrevivente")
|
||||
# Simplified external text stored in a dedicated key, consumed only by the Duplicidade builder.
|
||||
assert ctx["canceling_case_response_text"].startswith("Solicito o cancelamento")
|
||||
assert "202601150001234" in ctx["canceling_case_response_text"]
|
||||
assert len(ctx["canceling_case_response_text"]) <= 200
|
||||
assert result["current_step"] == "canceling_analysis_cancel_ticket"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_analysis_fails_open():
|
||||
"""LLM exception during duplicate analysis must NOT cancel a legitimate ticket."""
|
||||
state = _state_with(history=[{
|
||||
"complaintProtocol": "999",
|
||||
"openedAt": "2026-02-10T10:30:00-03:00",
|
||||
}])
|
||||
|
||||
cancel_resp = _llm_response({
|
||||
"decision": "continuar",
|
||||
"reasoning": "OK",
|
||||
"cancel_reason": None,
|
||||
})
|
||||
|
||||
def _llm_side_effect(*_args, **_kwargs):
|
||||
# First call: integrity check returns continuar; second call: blow up.
|
||||
if not hasattr(_llm_side_effect, "_called"):
|
||||
_llm_side_effect._called = True
|
||||
return cancel_resp
|
||||
raise RuntimeError("LLM service unavailable")
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
side_effect=_llm_side_effect,
|
||||
):
|
||||
result = await perform_canceling_analysis(state)
|
||||
|
||||
ctx = result["metadata"]["request_context"]
|
||||
# Fail-open: no cancellation triggered, ticket continues normally.
|
||||
assert ctx.get("siebel_action") is None
|
||||
assert ctx.get("duplicate_match") is None
|
||||
assert ctx.get("cancel_reason") is None
|
||||
assert result["current_step"] == "proceed_graph"
|
||||
canceling_decision = ctx["canceling_decision"]
|
||||
assert canceling_decision["decision"] == "continuar"
|
||||
assert "duplicity_analysis" not in canceling_decision
|
||||
assert "duplicate_match" not in canceling_decision
|
||||
# History + fail-open: reasoning combines integrity-check negative with the synthetic unavailable text.
|
||||
assert canceling_decision["reasoning"].startswith("OK")
|
||||
assert "Análise automática de duplicidade indisponível" in canceling_decision["reasoning"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_analysis_unknown_decision_fails_open():
|
||||
"""LLM returning an unknown decision string is treated like a failure (fail-open)."""
|
||||
state = _state_with(history=[{
|
||||
"complaintProtocol": "999",
|
||||
"openedAt": "2026-02-10T10:30:00-03:00",
|
||||
}])
|
||||
|
||||
cancel_resp = _llm_response({
|
||||
"decision": "continuar",
|
||||
"reasoning": "OK",
|
||||
"cancel_reason": None,
|
||||
})
|
||||
bogus_resp = _llm_response({
|
||||
"decision": "talvez", # invalid
|
||||
"reasoning": "incerto",
|
||||
"duplicate_match": None,
|
||||
})
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
side_effect=[cancel_resp, bogus_resp],
|
||||
):
|
||||
result = await perform_canceling_analysis(state)
|
||||
|
||||
ctx = result["metadata"]["request_context"]
|
||||
assert ctx.get("siebel_action") is None
|
||||
assert result["current_step"] == "proceed_graph"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_duplicate_payload_carries_history_fields():
|
||||
"""The duplicate prompt must receive every field of the contract."""
|
||||
history_item = {
|
||||
"complaintProtocol": "P1",
|
||||
"status": "Aberta",
|
||||
"actionType": "nova",
|
||||
"providerProtocol": None,
|
||||
"openedAt": "2026-02-10T10:30:00-03:00",
|
||||
"inputChannel": "anatel",
|
||||
"service": "Svc",
|
||||
"firstService": "FS",
|
||||
"modality": "M",
|
||||
"motive": "Mt",
|
||||
"description": "Desc histórica",
|
||||
"cpfCnpj": "12345678901",
|
||||
"msisdn": "5511988887777",
|
||||
"phones": ["+5511988887777"],
|
||||
}
|
||||
context = {
|
||||
"complaint": {
|
||||
"service": "Svc", "firstService": "FS", "modality": "M",
|
||||
"motive": "Mt", "description": "Desc atual",
|
||||
"openedAt": "2026-02-15T10:30:00-03:00",
|
||||
"complaintProtocol": "P2",
|
||||
},
|
||||
"customer": {
|
||||
"cpfCnpj": "12345678901",
|
||||
"msisdn": "5511988887777",
|
||||
"phones": ["+5511988887777"],
|
||||
},
|
||||
"complaintHistory": [history_item],
|
||||
}
|
||||
|
||||
captured = {}
|
||||
|
||||
def _capture(_llm, prompt: str, *_args, **_kwargs):
|
||||
captured["prompt"] = prompt
|
||||
return _llm_response({"decision": "nova", "reasoning": "ok", "duplicate_match": None})
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
side_effect=_capture,
|
||||
):
|
||||
parsed, meta = await _analyze_duplicate(context, session_id="sess-x")
|
||||
|
||||
assert parsed["decision"] == "nova"
|
||||
assert meta["prompt"] == captured["prompt"]
|
||||
# Every field from the contract is forwarded to the LLM.
|
||||
for field in (
|
||||
"complaintProtocol", "status", "actionType", "providerProtocol",
|
||||
"openedAt", "inputChannel", "service", "firstService", "modality",
|
||||
"motive", "description", "cpfCnpj", "msisdn", "phones",
|
||||
):
|
||||
assert field in captured["prompt"], f"missing {field} in duplicate payload"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_duplicate_raises_on_invalid_decision():
|
||||
"""The helper itself surfaces invalid decisions as DuplicateAnalysisError."""
|
||||
context = {"complaint": {}, "customer": {}, "complaintHistory": []}
|
||||
bogus_resp = _llm_response({"decision": "?", "reasoning": "x"})
|
||||
|
||||
with patch(
|
||||
"src.agent.nodes.canceling_analysis_node.chat_llm_with_usage",
|
||||
return_value=bogus_resp,
|
||||
):
|
||||
with pytest.raises(DuplicateAnalysisError):
|
||||
await _analyze_duplicate(context, session_id="sess-x")
|
||||
439
tests_original_develop/unit/test_config.py
Normal file
439
tests_original_develop/unit/test_config.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Unit tests for configuration module.
|
||||
|
||||
Tests specific examples and edge cases for configuration loading and validation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.core.config import Settings, LLMConfig, AgentConfig
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Test Settings class."""
|
||||
|
||||
def test_settings_with_required_fields(self):
|
||||
"""Test that Settings can be created with required fields."""
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4"
|
||||
)
|
||||
|
||||
assert settings.LLM_PROVIDER == "openai"
|
||||
assert settings.LLM_MODEL == "gpt-4"
|
||||
assert settings.APP_NAME == "Agent Microservice" # default value
|
||||
assert settings.DEBUG is False # default value
|
||||
assert settings.CLASSIFICATION_LLM_MODEL == "bo_gptoss20b_dev"
|
||||
assert settings.CLASSIFICATION_LLM_TEMPERATURE == 0.3
|
||||
|
||||
def test_settings_classification_custom_values(self):
|
||||
"""Test that Settings can be created with custom classification LLM values."""
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
CLASSIFICATION_LLM_MODEL="custom_model",
|
||||
CLASSIFICATION_LLM_TEMPERATURE=0.5,
|
||||
CLASSIFICATION_LLM_MAX_TOKENS=512,
|
||||
CLASSIFICATION_LLM_TOP_P=0.9
|
||||
)
|
||||
|
||||
assert settings.CLASSIFICATION_LLM_MODEL == "custom_model"
|
||||
assert settings.CLASSIFICATION_LLM_TEMPERATURE == 0.5
|
||||
assert settings.CLASSIFICATION_LLM_MAX_TOKENS == 512
|
||||
assert settings.CLASSIFICATION_LLM_TOP_P == 0.9
|
||||
|
||||
def test_settings_missing_required_field_llm_provider(self):
|
||||
"""Test that missing LLM_PROVIDER raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings(LLM_MODEL="gpt-4")
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['loc'] == ('LLM_PROVIDER',) for error in errors)
|
||||
|
||||
def test_settings_missing_required_field_llm_model(self):
|
||||
"""Test that missing LLM_MODEL raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings(LLM_PROVIDER="openai")
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any(error['loc'] == ('LLM_MODEL',) for error in errors)
|
||||
|
||||
def test_settings_invalid_llm_provider(self):
|
||||
"""Test that invalid LLM_PROVIDER raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Settings(
|
||||
LLM_PROVIDER="invalid_provider",
|
||||
LLM_MODEL="gpt-4"
|
||||
)
|
||||
|
||||
errors = exc_info.value.errors()
|
||||
assert any("LLM_PROVIDER" in str(error) for error in errors)
|
||||
|
||||
def test_settings_valid_providers(self):
|
||||
"""Test that valid providers are accepted."""
|
||||
for provider in ["openai", "anthropic"]:
|
||||
settings = Settings(
|
||||
LLM_PROVIDER=provider,
|
||||
LLM_MODEL="test-model"
|
||||
)
|
||||
assert settings.LLM_PROVIDER == provider
|
||||
|
||||
def test_settings_temperature_validation(self):
|
||||
"""Test temperature validation."""
|
||||
# Valid temperature
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LLM_TEMPERATURE=1.5
|
||||
)
|
||||
assert settings.LLM_TEMPERATURE == 1.5
|
||||
|
||||
# Temperature too low
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LLM_TEMPERATURE=-0.1
|
||||
)
|
||||
|
||||
# Temperature too high
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LLM_TEMPERATURE=2.1
|
||||
)
|
||||
|
||||
def test_settings_from_yaml_valid(self):
|
||||
"""Test loading settings from valid YAML file."""
|
||||
yaml_content = """
|
||||
APP_NAME: "Test Agent"
|
||||
VERSION: "2.0.0"
|
||||
DEBUG: true
|
||||
LLM_PROVIDER: "openai"
|
||||
LLM_MODEL: "gpt-3.5-turbo"
|
||||
LLM_TEMPERATURE: 0.5
|
||||
HOST: "127.0.0.1"
|
||||
PORT: 9000
|
||||
LOG_LEVEL: "DEBUG"
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
settings = Settings.from_yaml(temp_path)
|
||||
|
||||
assert settings.APP_NAME == "Test Agent"
|
||||
assert settings.VERSION == "2.0.0"
|
||||
assert settings.DEBUG is True
|
||||
assert settings.LLM_PROVIDER == "openai"
|
||||
assert settings.LLM_MODEL == "gpt-3.5-turbo"
|
||||
assert settings.LLM_TEMPERATURE == 0.5
|
||||
assert settings.HOST == "127.0.0.1"
|
||||
assert settings.PORT == 9000
|
||||
assert settings.LOG_LEVEL == "DEBUG"
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_settings_from_yaml_missing_required(self):
|
||||
"""Test that loading YAML without required fields raises error."""
|
||||
yaml_content = """
|
||||
APP_NAME: "Test Agent"
|
||||
VERSION: "2.0.0"
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
f.write(yaml_content)
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings.from_yaml(temp_path)
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_settings_from_yaml_file_not_found(self):
|
||||
"""Test that loading from non-existent file raises FileNotFoundError."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
Settings.from_yaml("/nonexistent/path/config.yaml")
|
||||
|
||||
def test_settings_from_yaml_empty_file(self):
|
||||
"""Test loading from empty YAML file."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
||||
f.write("")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings.from_yaml(temp_path)
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_settings_log_level_validation(self):
|
||||
"""Test log level validation."""
|
||||
# Valid log levels
|
||||
for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LOG_LEVEL=level
|
||||
)
|
||||
assert settings.LOG_LEVEL == level
|
||||
|
||||
# Case insensitive
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LOG_LEVEL="debug"
|
||||
)
|
||||
assert settings.LOG_LEVEL == "DEBUG"
|
||||
|
||||
# Invalid log level
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LOG_LEVEL="INVALID"
|
||||
)
|
||||
|
||||
def test_settings_log_format_validation(self):
|
||||
"""Test log format validation."""
|
||||
# Valid formats
|
||||
for fmt in ["json", "text"]:
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LOG_FORMAT=fmt
|
||||
)
|
||||
assert settings.LOG_FORMAT == fmt
|
||||
|
||||
# Case insensitive
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LOG_FORMAT="JSON"
|
||||
)
|
||||
assert settings.LOG_FORMAT == "json"
|
||||
|
||||
# Invalid format
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LOG_FORMAT="xml"
|
||||
)
|
||||
|
||||
def test_settings_port_validation(self):
|
||||
"""Test port number validation."""
|
||||
# Valid port
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
PORT=8080
|
||||
)
|
||||
assert settings.PORT == 8080
|
||||
|
||||
# Port too low
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
PORT=0
|
||||
)
|
||||
|
||||
# Port too high
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
PORT=65536
|
||||
)
|
||||
|
||||
|
||||
class TestLLMConfig:
|
||||
"""Test LLMConfig model."""
|
||||
|
||||
def test_llm_config_valid(self):
|
||||
"""Test creating valid LLMConfig."""
|
||||
config = LLMConfig(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
temperature=0.8,
|
||||
max_tokens=1000
|
||||
)
|
||||
|
||||
assert config.provider == "openai"
|
||||
assert config.model == "gpt-4"
|
||||
assert config.temperature == 0.8
|
||||
assert config.max_tokens == 1000
|
||||
|
||||
def test_llm_config_default_temperature(self):
|
||||
"""Test default temperature value."""
|
||||
config = LLMConfig(
|
||||
provider="openai",
|
||||
model="gpt-4"
|
||||
)
|
||||
|
||||
assert config.temperature == 0.7
|
||||
|
||||
def test_llm_config_invalid_provider(self):
|
||||
"""Test that invalid provider raises error."""
|
||||
with pytest.raises(ValidationError):
|
||||
LLMConfig(
|
||||
provider="invalid",
|
||||
model="gpt-4"
|
||||
)
|
||||
|
||||
def test_llm_config_temperature_bounds(self):
|
||||
"""Test temperature boundary validation."""
|
||||
# Valid boundaries
|
||||
LLMConfig(provider="openai", model="gpt-4", temperature=0.0)
|
||||
LLMConfig(provider="openai", model="gpt-4", temperature=2.0)
|
||||
|
||||
# Invalid boundaries
|
||||
with pytest.raises(ValidationError):
|
||||
LLMConfig(provider="openai", model="gpt-4", temperature=-0.1)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
LLMConfig(provider="openai", model="gpt-4", temperature=2.1)
|
||||
|
||||
def test_llm_config_max_tokens_validation(self):
|
||||
"""Test max_tokens validation."""
|
||||
# Valid max_tokens
|
||||
config = LLMConfig(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
max_tokens=100
|
||||
)
|
||||
assert config.max_tokens == 100
|
||||
|
||||
# None is valid
|
||||
config = LLMConfig(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
max_tokens=None
|
||||
)
|
||||
assert config.max_tokens is None
|
||||
|
||||
# Zero or negative is invalid
|
||||
with pytest.raises(ValidationError):
|
||||
LLMConfig(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
max_tokens=0
|
||||
)
|
||||
|
||||
|
||||
class TestAgentConfig:
|
||||
"""Test AgentConfig model."""
|
||||
|
||||
def test_agent_config_valid(self):
|
||||
"""Test creating valid AgentConfig."""
|
||||
config = AgentConfig(
|
||||
name="Test Agent",
|
||||
description="A test agent",
|
||||
system_prompt="You are a test assistant",
|
||||
tools=["tool1", "tool2"],
|
||||
max_iterations=5
|
||||
)
|
||||
|
||||
assert config.name == "Test Agent"
|
||||
assert config.description == "A test agent"
|
||||
assert config.system_prompt == "You are a test assistant"
|
||||
assert config.tools == ["tool1", "tool2"]
|
||||
assert config.max_iterations == 5
|
||||
|
||||
def test_agent_config_default_tools(self):
|
||||
"""Test default empty tools list."""
|
||||
config = AgentConfig(
|
||||
name="Test Agent",
|
||||
description="A test agent",
|
||||
system_prompt="You are a test assistant"
|
||||
)
|
||||
|
||||
assert config.tools == []
|
||||
|
||||
def test_agent_config_default_max_iterations(self):
|
||||
"""Test default max_iterations value."""
|
||||
config = AgentConfig(
|
||||
name="Test Agent",
|
||||
description="A test agent",
|
||||
system_prompt="You are a test assistant"
|
||||
)
|
||||
|
||||
assert config.max_iterations == 10
|
||||
|
||||
def test_agent_config_max_iterations_validation(self):
|
||||
"""Test max_iterations must be positive."""
|
||||
# Valid
|
||||
AgentConfig(
|
||||
name="Test Agent",
|
||||
description="A test agent",
|
||||
system_prompt="You are a test assistant",
|
||||
max_iterations=1
|
||||
)
|
||||
|
||||
# Invalid
|
||||
with pytest.raises(ValidationError):
|
||||
AgentConfig(
|
||||
name="Test Agent",
|
||||
description="A test agent",
|
||||
system_prompt="You are a test assistant",
|
||||
max_iterations=0
|
||||
)
|
||||
|
||||
|
||||
class TestSettingsConversion:
|
||||
"""Test Settings conversion methods."""
|
||||
|
||||
def test_to_llm_config(self):
|
||||
"""Test converting Settings to LLMConfig."""
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LLM_TEMPERATURE=0.9,
|
||||
LLM_MAX_TOKENS=2000
|
||||
)
|
||||
|
||||
llm_config = settings.to_llm_config()
|
||||
|
||||
assert isinstance(llm_config, LLMConfig)
|
||||
assert llm_config.provider == "openai"
|
||||
assert llm_config.model == "gpt-4"
|
||||
assert llm_config.temperature == 0.9
|
||||
assert llm_config.max_tokens == 2000
|
||||
|
||||
def test_to_agent_config_with_all_fields(self):
|
||||
"""Test converting Settings to AgentConfig when all fields present."""
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
AGENT_NAME="My Agent",
|
||||
AGENT_DESCRIPTION="My agent description",
|
||||
AGENT_SYSTEM_PROMPT="You are my agent",
|
||||
AGENT_MAX_ITERATIONS=15
|
||||
)
|
||||
|
||||
agent_config = settings.to_agent_config()
|
||||
|
||||
assert agent_config is not None
|
||||
assert isinstance(agent_config, AgentConfig)
|
||||
assert agent_config.name == "My Agent"
|
||||
assert agent_config.description == "My agent description"
|
||||
assert agent_config.system_prompt == "You are my agent"
|
||||
assert agent_config.max_iterations == 15
|
||||
|
||||
def test_to_agent_config_missing_fields(self):
|
||||
"""Test that to_agent_config returns None when fields missing."""
|
||||
settings = Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4"
|
||||
)
|
||||
|
||||
agent_config = settings.to_agent_config()
|
||||
|
||||
assert agent_config is None
|
||||
272
tests_original_develop/unit/test_emulator_close_case_node.py
Normal file
272
tests_original_develop/unit/test_emulator_close_case_node.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""Unit tests for the emulator close_case_node — focused on the Siebel SR closing
|
||||
step that runs after the DB update + OCI publish."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from src.agent.nodes.emulator import close_case_node
|
||||
from src.api.schemas.siebel_schemas import SiebelSRStatusRequestPosPago
|
||||
from src.components.clients.exceptions.siebel_exceptions import (
|
||||
SiebelClientError,
|
||||
SiebelHttpError,
|
||||
SiebelConnectionError,
|
||||
SiebelTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CASE_DATA = {
|
||||
"siebel_sr_data": {"interactionProtocol": "2026000068320"},
|
||||
"complaint": {"complaintProtocol": "12345678"},
|
||||
"imdb_access_data": {"plan": {"Type": "pós-pago"}},
|
||||
}
|
||||
|
||||
_SIEBEL_SUCCESS_RESPONSE = {"status": "ok"}
|
||||
_HTTP_META = {"url": "https://fake", "status_code": 200, "response_text": "{}", "latency_ms": 50, "retry_count": 0}
|
||||
|
||||
|
||||
def _make_state(
|
||||
case_data: dict = None,
|
||||
case_response: str = "Reclamação analisada e encaminhada para resolução.",
|
||||
transaction_id: str = "tx-1",
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
metadata = {
|
||||
"transaction_id": transaction_id,
|
||||
"case_response": case_response,
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
if case_data is not None:
|
||||
metadata["case_data"] = case_data
|
||||
return {
|
||||
"messages": [HumanMessage(content="test")],
|
||||
"current_step": "validate_response",
|
||||
"iteration_count": 1,
|
||||
"tool_results": None,
|
||||
"final_response": None,
|
||||
"session_id": "test-session",
|
||||
"metadata": metadata,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _make_mock_siebel_client(side_effect=None):
|
||||
mock = MagicMock()
|
||||
if side_effect:
|
||||
mock.update_service_request_status_with_retry = AsyncMock(side_effect=side_effect)
|
||||
else:
|
||||
mock.update_service_request_status_with_retry = AsyncMock(
|
||||
return_value=(_SIEBEL_SUCCESS_RESPONSE, _HTTP_META),
|
||||
)
|
||||
return mock
|
||||
|
||||
|
||||
def _patch_close_case_deps(siebel_mock):
|
||||
"""Patches DB, OCI producer and SiebelClient in one go."""
|
||||
return (
|
||||
patch("src.agent.nodes.emulator.close_case_node._update_case_in_db", AsyncMock(return_value=None)),
|
||||
patch("src.agent.nodes.emulator.close_case_node._publish_response_event", AsyncMock(return_value=None)),
|
||||
patch("src.agent.nodes.emulator.close_case_node.SiebelClient", return_value=siebel_mock),
|
||||
patch(
|
||||
"src.agent.nodes.emulator.close_case_node.build_emulator_response_event",
|
||||
return_value=MagicMock(model_dump=lambda: {"transactionId": "tx-1"}, transactionId="tx-1"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SiebelSRStatusRequestPosPago — build_notes e to_payload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_build_notes_format():
|
||||
notes = SiebelSRStatusRequestPosPago.build_notes("12345678", "Resposta aceita pelo consultor.")
|
||||
assert "12345678" in notes
|
||||
assert "Resposta aceita pelo consultor." in notes
|
||||
assert notes.startswith("Atendimento finalizado para a reclamação Anatel")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_to_payload_structure():
|
||||
req = SiebelSRStatusRequestPosPago(
|
||||
protocol_number="2026000068320",
|
||||
notes="Nota de teste.",
|
||||
date="05/19/2026 17:31:50",
|
||||
)
|
||||
payload = req.to_payload()
|
||||
assert payload["channel"] == "Anatel"
|
||||
assert payload["serviceRequest"]["protocolNumber"] == "2026000068320"
|
||||
assert payload["serviceRequest"]["status"] == "Fechado"
|
||||
assert payload["serviceRequest"]["notes"] == "Nota de teste."
|
||||
assert payload["date"] == "05/19/2026 17:31:50"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path — full close_case (DB + OCI + Siebel)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_full_flow_marks_case_closed():
|
||||
mock_client = _make_mock_siebel_client()
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
result = await close_case_node.close_case(_make_state(case_data=_CASE_DATA))
|
||||
|
||||
assert result["current_step"] == "case_closed"
|
||||
assert result["error"] is None
|
||||
assert result["metadata"]["siebel_sr_closing_data"] == _SIEBEL_SUCCESS_RESPONSE
|
||||
assert result["metadata"]["siebel_sr_closing_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_payload_carries_protocol_and_notes():
|
||||
mock_client = _make_mock_siebel_client()
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
await close_case_node.close_case(_make_state(case_data=_CASE_DATA))
|
||||
|
||||
call = mock_client.update_service_request_status_with_retry.call_args
|
||||
payload = call.kwargs.get("payload") or call.args[0]
|
||||
assert payload["serviceRequest"]["protocolNumber"] == "2026000068320"
|
||||
assert payload["serviceRequest"]["status"] == "Fechado"
|
||||
assert "12345678" in payload["serviceRequest"]["notes"]
|
||||
assert "Reclamação analisada" in payload["serviceRequest"]["notes"]
|
||||
assert call.kwargs.get("plan_type") == "pós-pago"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_express_plan_routes_to_prepago():
|
||||
case_data = {
|
||||
**_CASE_DATA,
|
||||
"imdb_access_data": {"plan": {"Type": "Express"}},
|
||||
"customer": {"msisdn": "11999999999"},
|
||||
}
|
||||
mock_client = _make_mock_siebel_client()
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
await close_case_node.close_case(_make_state(case_data=case_data))
|
||||
|
||||
call = mock_client.update_service_request_status_with_retry.call_args
|
||||
assert call.kwargs.get("plan_type") == "Express"
|
||||
payload = call.kwargs.get("payload") or call.args[0]
|
||||
assert payload["msisdn"] == "11999999999"
|
||||
assert payload["status"] == "Closed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry run skips Siebel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_skips_siebel():
|
||||
mock_client = _make_mock_siebel_client()
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
result = await close_case_node.close_case(
|
||||
_make_state(case_data=_CASE_DATA, dry_run=True),
|
||||
)
|
||||
|
||||
mock_client.update_service_request_status_with_retry.assert_not_awaited()
|
||||
assert result["current_step"] == "case_closed"
|
||||
assert result["error"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation errors on Siebel payload — non-blocking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_protocol_records_error_but_closes_case():
|
||||
case_data = {**_CASE_DATA, "siebel_sr_data": {}}
|
||||
mock_client = _make_mock_siebel_client()
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
result = await close_case_node.close_case(_make_state(case_data=case_data))
|
||||
|
||||
mock_client.update_service_request_status_with_retry.assert_not_awaited()
|
||||
assert result["current_step"] == "case_closed"
|
||||
assert result["error"] is None
|
||||
assert result["metadata"]["siebel_sr_closing_error"]["type"] == "ValidationError"
|
||||
assert "interactionProtocol" in result["metadata"]["siebel_sr_closing_error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_complaint_protocol_records_error():
|
||||
case_data = {**_CASE_DATA, "complaint": {}}
|
||||
mock_client = _make_mock_siebel_client()
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
result = await close_case_node.close_case(_make_state(case_data=case_data))
|
||||
|
||||
assert result["current_step"] == "case_closed"
|
||||
assert result["metadata"]["siebel_sr_closing_error"]["type"] == "ValidationError"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepago_missing_msisdn_records_error():
|
||||
case_data = {**_CASE_DATA, "imdb_access_data": {"plan": {"Type": "Pré-Pago"}}}
|
||||
mock_client = _make_mock_siebel_client()
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
result = await close_case_node.close_case(_make_state(case_data=case_data))
|
||||
|
||||
assert result["metadata"]["siebel_sr_closing_error"]["type"] == "ValidationError"
|
||||
assert "msisdn" in result["metadata"]["siebel_sr_closing_error"]["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Siebel client errors — non-blocking (case still closed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("exception, expected_type", [
|
||||
(SiebelHttpError(500, "Internal Server Error"), "SiebelHttpError"),
|
||||
(SiebelConnectionError("Connection refused"), "SiebelConnectionError"),
|
||||
(SiebelTimeoutError("Request timed out"), "SiebelTimeoutError"),
|
||||
(SiebelClientError("Unexpected error"), "SiebelClientError"),
|
||||
])
|
||||
async def test_client_errors_recorded_but_case_closes(exception, expected_type):
|
||||
mock_client = _make_mock_siebel_client(side_effect=exception)
|
||||
patches = _patch_close_case_deps(mock_client)
|
||||
with patches[0], patches[1], patches[2], patches[3]:
|
||||
result = await close_case_node.close_case(_make_state(case_data=_CASE_DATA))
|
||||
|
||||
assert result["current_step"] == "case_closed"
|
||||
assert result["error"] is None
|
||||
assert result["metadata"]["siebel_sr_closing_error"]["type"] == expected_type
|
||||
assert result["metadata"]["siebel_sr_closing_data"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-Siebel errors — still bloqueiam (DB/OCI são críticos)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_transaction_id_returns_error():
|
||||
state = _make_state(case_data=_CASE_DATA)
|
||||
state["metadata"]["transaction_id"] = None
|
||||
result = await close_case_node.close_case(state)
|
||||
assert result["error"]["type"] == "CloseCaseError"
|
||||
assert result["current_step"] == "close_case_failed"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_case_response_returns_error():
|
||||
state = _make_state(case_data=_CASE_DATA, case_response="")
|
||||
result = await close_case_node.close_case(state)
|
||||
assert result["error"]["type"] == "CloseCaseError"
|
||||
assert result["current_step"] == "close_case_failed"
|
||||
352
tests_original_develop/unit/test_identity_verification.py
Normal file
352
tests_original_develop/unit/test_identity_verification.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Unit tests for identity_verification_node."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent.nodes.identity_verification_node import (
|
||||
IDENTITY_DECISION_CANCEL,
|
||||
IDENTITY_DECISION_PROCEED,
|
||||
IDENTITY_DECISION_SMART_HUMAN,
|
||||
_authorized_via_attachment,
|
||||
_authorized_via_gov_br_seal,
|
||||
_is_third_party_complaint,
|
||||
_normalize_cpf,
|
||||
perform_identity_verification,
|
||||
route_after_identity_verification,
|
||||
)
|
||||
from src.utils.decision_helpers import CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY
|
||||
|
||||
CANCELATION_REASON_1 = "Processo Interno"
|
||||
CANCELATION_REASON_2 = "Atendimento"
|
||||
CANCELATION_REASON_3 = "Cancelamento ID Anatel"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TITULAR_CPF = "12345678901"
|
||||
TERCEIRO_CPF = "99988877766"
|
||||
ASSINANTE_CPF = "11122233344"
|
||||
|
||||
|
||||
def _make_state(
|
||||
customer_cpf=TITULAR_CPF,
|
||||
subscriber_cpf=TITULAR_CPF,
|
||||
imdb_cpf=TITULAR_CPF,
|
||||
imdb_status=200,
|
||||
gov_br_seal=None,
|
||||
has_attachment=None,
|
||||
omit_context=False,
|
||||
):
|
||||
"""Build a minimal AgentState driving the identity_verification node."""
|
||||
customer = {
|
||||
"cpfCnpj": customer_cpf,
|
||||
"subscriber": {"cpfCnpj": subscriber_cpf} if subscriber_cpf is not None else {},
|
||||
}
|
||||
if gov_br_seal is not None:
|
||||
customer["govBrSeal"] = gov_br_seal
|
||||
if has_attachment is not None:
|
||||
customer["hasAttachment"] = has_attachment
|
||||
|
||||
request_context = {
|
||||
"customer": customer,
|
||||
# `cpf_cnpj` continua sendo o eco do customer.cpfCnpj (consumido pelo
|
||||
# cache_check para invalidação). A comparação real do titular usa
|
||||
# `socialSecNo` (campo retornado pela IMDB).
|
||||
"imdb_access_data": {
|
||||
"status_code": imdb_status,
|
||||
"cpf_cnpj": customer_cpf,
|
||||
"socialSecNo": imdb_cpf,
|
||||
},
|
||||
}
|
||||
|
||||
metadata = {} if omit_context else {"request_context": request_context}
|
||||
return {
|
||||
"messages": [],
|
||||
"current_step": "imdb_enrichment_completed",
|
||||
"iteration_count": 0,
|
||||
"tool_results": None,
|
||||
"final_response": None,
|
||||
"session_id": "sess-idv-test",
|
||||
"metadata": metadata,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _idv(state):
|
||||
"""Shortcut: read identity_verification snapshot from state."""
|
||||
return state["metadata"]["request_context"]["identity_verification"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Tabela de decisão — os 4 cenários principais
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
class TestDecisionTable:
|
||||
|
||||
async def test_titular_proceeds(self):
|
||||
"""Customer == Subscriber == IMDB → proceed (fluxo normal)."""
|
||||
state = await perform_identity_verification(_make_state())
|
||||
|
||||
snap = _idv(state)
|
||||
assert snap["decision"] == IDENTITY_DECISION_PROCEED
|
||||
assert snap["third_party_complaint"] is False
|
||||
assert snap["divergence_source"] is None
|
||||
assert state["current_step"] == "identity_verification"
|
||||
# PROCEED não deve ativar smart human nem cancelamento
|
||||
ctx = state["metadata"]["request_context"]
|
||||
assert "smart_human_handling" not in ctx
|
||||
assert ctx.get("siebel_action") != "cancelar"
|
||||
|
||||
async def test_terceiro_com_gov_br_vai_para_smart_human(self):
|
||||
"""Customer ≠ Subscriber + Selo GOV BR autenticado → smart_human."""
|
||||
state = await perform_identity_verification(_make_state(
|
||||
customer_cpf=TERCEIRO_CPF,
|
||||
subscriber_cpf=ASSINANTE_CPF,
|
||||
imdb_cpf=ASSINANTE_CPF,
|
||||
gov_br_seal=True,
|
||||
))
|
||||
|
||||
snap = _idv(state)
|
||||
assert snap["decision"] == IDENTITY_DECISION_SMART_HUMAN
|
||||
assert snap["third_party_complaint"] is True
|
||||
assert snap["divergence_source"] == "subscriber"
|
||||
assert snap["authorized_via_gov_br_seal"] is True
|
||||
|
||||
ctx = state["metadata"]["request_context"]
|
||||
assert ctx["smart_human_handling"] is True
|
||||
assert "Selo GOV BR" in ctx["smart_human_reason"]
|
||||
assert ctx.get("siebel_action") != "cancelar"
|
||||
|
||||
async def test_terceiro_com_anexo_vai_para_smart_human(self):
|
||||
"""Customer ≠ Subscriber + sem selo + anexo presente → smart_human."""
|
||||
state = await perform_identity_verification(_make_state(
|
||||
customer_cpf=TERCEIRO_CPF,
|
||||
subscriber_cpf=ASSINANTE_CPF,
|
||||
imdb_cpf=ASSINANTE_CPF,
|
||||
gov_br_seal=False,
|
||||
has_attachment=True,
|
||||
))
|
||||
|
||||
snap = _idv(state)
|
||||
assert snap["decision"] == IDENTITY_DECISION_SMART_HUMAN
|
||||
assert snap["third_party_complaint"] is True
|
||||
assert snap["authorized_via_gov_br_seal"] is False
|
||||
assert snap["authorized_via_attachment"] is True
|
||||
|
||||
ctx = state["metadata"]["request_context"]
|
||||
assert ctx["smart_human_handling"] is True
|
||||
assert "anexo" in ctx["smart_human_reason"].lower()
|
||||
|
||||
async def test_terceiro_nao_autorizado_e_cancelado(self):
|
||||
"""Customer ≠ Subscriber + sem selo + sem anexo → cancel + triplet Siebel."""
|
||||
state = await perform_identity_verification(_make_state(
|
||||
customer_cpf=TERCEIRO_CPF,
|
||||
subscriber_cpf=ASSINANTE_CPF,
|
||||
imdb_cpf=ASSINANTE_CPF,
|
||||
gov_br_seal=False,
|
||||
has_attachment=False,
|
||||
))
|
||||
|
||||
snap = _idv(state)
|
||||
assert snap["decision"] == IDENTITY_DECISION_CANCEL
|
||||
assert snap["third_party_complaint"] is True
|
||||
assert snap["authorized_via_gov_br_seal"] is False
|
||||
assert snap["authorized_via_attachment"] is False
|
||||
|
||||
ctx = state["metadata"]["request_context"]
|
||||
assert ctx["siebel_action"] == "cancelar"
|
||||
assert ctx["reason1"] == CANCELATION_REASON_1
|
||||
assert ctx["reason2"] == CANCELATION_REASON_2
|
||||
assert ctx["reason3"] == CANCELATION_REASON_3
|
||||
assert ctx["cancel_reason"] == CANCEL_REASON_UNAUTHORIZED_THIRD_PARTY
|
||||
assert ctx["canceling_reasoning"] == snap["reasoning"]
|
||||
# identity_verification cancela inline e move o step para o ramo de cancelamento
|
||||
assert state["current_step"] == "canceling_analysis_cancel_ticket"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Detecção de "reclamação por terceiro"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestThirdPartyDetection:
|
||||
|
||||
def test_subscriber_divergence_takes_priority(self):
|
||||
customer = {"cpfCnpj": TERCEIRO_CPF, "subscriber": {"cpfCnpj": ASSINANTE_CPF}}
|
||||
imdb = {"status_code": 200, "socialSecNo": TITULAR_CPF} # divergente também, mas nem é alcançado
|
||||
third, src = _is_third_party_complaint(customer, imdb)
|
||||
assert third is True
|
||||
assert src == "subscriber"
|
||||
|
||||
def test_imdb_divergence_when_subscriber_matches(self):
|
||||
customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}}
|
||||
imdb = {"status_code": 200, "socialSecNo": TERCEIRO_CPF}
|
||||
third, src = _is_third_party_complaint(customer, imdb)
|
||||
assert third is True
|
||||
assert src == "imdb"
|
||||
|
||||
def test_imdb_cpf_echo_field_is_not_used_for_comparison(self):
|
||||
"""`cpf_cnpj` é eco do customer e não deve disparar divergência."""
|
||||
customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}}
|
||||
imdb = {"status_code": 200, "cpf_cnpj": TERCEIRO_CPF, "socialSecNo": TITULAR_CPF}
|
||||
third, _ = _is_third_party_complaint(customer, imdb)
|
||||
assert third is False
|
||||
|
||||
def test_imdb_204_is_ignored(self):
|
||||
"""IMDB 204 (no content) — comparison must be skipped per the rule."""
|
||||
customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}}
|
||||
imdb = {"status_code": 204, "socialSecNo": TERCEIRO_CPF}
|
||||
third, src = _is_third_party_complaint(customer, imdb)
|
||||
assert third is False
|
||||
assert src is None
|
||||
|
||||
def test_imdb_200_without_cpf_is_ignored(self):
|
||||
"""IMDB respondeu 200 mas sem socialSecNo → não comparar."""
|
||||
customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}}
|
||||
imdb = {"status_code": 200, "socialSecNo": None}
|
||||
third, _ = _is_third_party_complaint(customer, imdb)
|
||||
assert third is False
|
||||
|
||||
def test_non_200_status_is_ignored(self):
|
||||
"""Qualquer status != 200 (timeout, 5xx) → comparação IMDB silenciada."""
|
||||
customer = {"cpfCnpj": TITULAR_CPF, "subscriber": {"cpfCnpj": TITULAR_CPF}}
|
||||
imdb = {"status_code": 500, "socialSecNo": TERCEIRO_CPF}
|
||||
third, _ = _is_third_party_complaint(customer, imdb)
|
||||
assert third is False
|
||||
|
||||
def test_formatted_cpfs_are_normalized(self):
|
||||
"""CPFs com pontos/traços devem ser comparados pelos dígitos."""
|
||||
customer = {
|
||||
"cpfCnpj": "123.456.789-01",
|
||||
"subscriber": {"cpfCnpj": "12345678901"},
|
||||
}
|
||||
imdb = {"status_code": 200, "socialSecNo": "123-456-789/01"}
|
||||
third, _ = _is_third_party_complaint(customer, imdb)
|
||||
assert third is False
|
||||
|
||||
def test_missing_customer_cpf_skips_comparison(self):
|
||||
customer = {"cpfCnpj": None, "subscriber": {"cpfCnpj": TITULAR_CPF}}
|
||||
imdb = {"status_code": 200, "socialSecNo": TERCEIRO_CPF}
|
||||
third, _ = _is_third_party_complaint(customer, imdb)
|
||||
assert third is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Selo GOV BR e Anexo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGovBrSeal:
|
||||
|
||||
@pytest.mark.parametrize("value, expected", [
|
||||
(True, True),
|
||||
(False, False),
|
||||
(None, False),
|
||||
("", False),
|
||||
(" ", False), # whitespace só
|
||||
("autenticado", True),
|
||||
])
|
||||
def test_seal_variants(self, value, expected):
|
||||
assert _authorized_via_gov_br_seal({"govBrSeal": value}) is expected
|
||||
|
||||
def test_seal_missing_key(self):
|
||||
assert _authorized_via_gov_br_seal({}) is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAttachmentAuth:
|
||||
|
||||
@pytest.mark.parametrize("value, expected", [
|
||||
(True, True),
|
||||
(False, False),
|
||||
(None, False),
|
||||
("true", False), # apenas bool literal True conta
|
||||
(1, False),
|
||||
])
|
||||
def test_attachment_variants(self, value, expected):
|
||||
assert _authorized_via_attachment({"hasAttachment": value}) is expected
|
||||
|
||||
def test_attachment_missing_key(self):
|
||||
assert _authorized_via_attachment({}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Normalização de CPF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizeCpf:
|
||||
|
||||
@pytest.mark.parametrize("raw, expected", [
|
||||
("12345678901", "12345678901"),
|
||||
("123.456.789-01", "12345678901"),
|
||||
(" 123 456 789 01 ", "12345678901"),
|
||||
("", None),
|
||||
("--..", None),
|
||||
(None, None),
|
||||
])
|
||||
def test_normalize(self, raw, expected):
|
||||
assert _normalize_cpf(raw) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Erros e contratos do node
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
class TestNodeContract:
|
||||
|
||||
async def test_missing_request_context_sets_validation_error(self):
|
||||
state = await perform_identity_verification(_make_state(omit_context=True))
|
||||
assert state["error"]["type"] == "ValidationError"
|
||||
assert state["error"]["step"] == "identity_verification"
|
||||
|
||||
async def test_state_snapshot_always_populated(self):
|
||||
"""Toda decisão bem-sucedida grava um snapshot completo."""
|
||||
state = await perform_identity_verification(_make_state(
|
||||
customer_cpf=TERCEIRO_CPF,
|
||||
subscriber_cpf=ASSINANTE_CPF,
|
||||
gov_br_seal=True,
|
||||
))
|
||||
snap = _idv(state)
|
||||
for key in (
|
||||
"decision",
|
||||
"third_party_complaint",
|
||||
"divergence_source",
|
||||
"authorized_via_gov_br_seal",
|
||||
"authorized_via_attachment",
|
||||
"reasoning",
|
||||
):
|
||||
assert key in snap
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Router (mapeamento decisão → próximo node)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRouter:
|
||||
|
||||
@pytest.mark.parametrize("decision, expected_route", [
|
||||
(IDENTITY_DECISION_PROCEED, "proceed"),
|
||||
(IDENTITY_DECISION_SMART_HUMAN, "smart_human"),
|
||||
(IDENTITY_DECISION_CANCEL, "cancel"),
|
||||
("anything_else", "failed"),
|
||||
(None, "failed"),
|
||||
])
|
||||
def test_routing(self, decision, expected_route):
|
||||
state = {
|
||||
"metadata": {
|
||||
"request_context": {"identity_verification": {"decision": decision}}
|
||||
}
|
||||
}
|
||||
assert route_after_identity_verification(state) == expected_route
|
||||
|
||||
def test_routing_missing_identity_section(self):
|
||||
"""Sem snapshot de identity_verification → 'failed'."""
|
||||
state = {"metadata": {"request_context": {}}}
|
||||
assert route_after_identity_verification(state) == "failed"
|
||||
288
tests_original_develop/unit/test_imdb_client.py
Normal file
288
tests_original_develop/unit/test_imdb_client.py
Normal file
@@ -0,0 +1,288 @@
|
||||
import json
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from src.components.clients.imdb_client import ImdbClient
|
||||
from src.components.clients.exceptions.imdb_exceptions import (
|
||||
ImdbClientError,
|
||||
ImdbHttpError,
|
||||
ImdbTimeoutError,
|
||||
ImdbConnectionError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return ImdbClient()
|
||||
|
||||
|
||||
def _make_mock_response(status_code: int, json_data: dict = None, content: bytes = b"data", headers: dict = None):
|
||||
"""Helper to create a mock httpx.Response."""
|
||||
mock_resp = MagicMock(spec=httpx.Response)
|
||||
mock_resp.status_code = status_code
|
||||
mock_resp.content = content
|
||||
mock_resp.headers = headers or {}
|
||||
if json_data is not None:
|
||||
mock_resp.json.return_value = json_data
|
||||
return mock_resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — helper methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildHeaders:
|
||||
|
||||
def test_build_headers_without_message_id(self, client):
|
||||
headers = client._build_headers(client_id="test-client")
|
||||
assert headers["clientId"] == "test-client"
|
||||
assert "Authorization" in headers
|
||||
assert headers["Accept"] == "application/json"
|
||||
assert "messageId" not in headers
|
||||
|
||||
def test_build_headers_with_client_id(self, client):
|
||||
headers = client._build_headers(client_id="test-client")
|
||||
assert headers["clientId"] == "test-client"
|
||||
assert "Authorization" in headers
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildUrl:
|
||||
|
||||
def test_build_url_appends_msisdn(self, client):
|
||||
msisdn = "5511999999999"
|
||||
url = client._build_url(msisdn)
|
||||
assert url.endswith(msisdn)
|
||||
assert msisdn in url
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestParseResponse:
|
||||
|
||||
def test_parse_response_valid_json(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"plan": {"name": "TIM Black"},
|
||||
"statusType": "Active",
|
||||
"statusDescription": "Full Access",
|
||||
"socialSecNo": "06252533106",
|
||||
"extraField": "ignored",
|
||||
}
|
||||
result = client._parse_response(mock_resp)
|
||||
assert result.plan == {"name": "TIM Black"}
|
||||
assert result.statusType == "Active"
|
||||
assert result.statusDescription == "Full Access"
|
||||
assert result.socialSecNo == "06252533106"
|
||||
|
||||
def test_parse_response_missing_optional_keys(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"plan": None,
|
||||
"statusType": None,
|
||||
"statusDescription": None
|
||||
}
|
||||
result = client._parse_response(mock_resp)
|
||||
assert result.plan is None
|
||||
assert result.statusType is None
|
||||
assert result.statusDescription is None
|
||||
assert result.socialSecNo is None
|
||||
|
||||
def test_parse_response_invalid_json_raises_imdb_client_error(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.side_effect = json.JSONDecodeError("error", "", 0)
|
||||
with pytest.raises(ImdbClientError, match="Invalid JSON response from IMDB API"):
|
||||
client._parse_response(mock_resp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — get_imdb_access_data (async)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
class TestGetImdbAccessData:
|
||||
|
||||
@patch("src.components.clients.imdb_client.traced_async_client")
|
||||
async def test_success_returns_parsed_data(self, mock_async_client_cls, client):
|
||||
# Arrange
|
||||
json_data = {"plan": {"name": "TIM Black"}, "statusType": "Active", "statusDescription": "Full Access"}
|
||||
mock_resp = _make_mock_response(200, json_data=json_data, headers={"Messageid": "msg-123"})
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.get = AsyncMock(return_value=mock_resp)
|
||||
mock_async_client_cls.return_value = mock_ctx
|
||||
|
||||
# Act
|
||||
result = await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
# Assert
|
||||
assert result.plan == {"name": "TIM Black"}
|
||||
assert result.statusType == "Active"
|
||||
assert result.statusDescription == "Full Access"
|
||||
|
||||
@patch("src.components.clients.imdb_client.traced_async_client")
|
||||
async def test_204_no_content_returns_none(self, mock_async_client_cls, client):
|
||||
# Arrange
|
||||
mock_resp = _make_mock_response(204, content=b"")
|
||||
mock_resp.content = b""
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.get = AsyncMock(return_value=mock_resp)
|
||||
mock_async_client_cls.return_value = mock_ctx
|
||||
|
||||
# Act
|
||||
result = await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@patch("src.components.clients.imdb_client.traced_async_client")
|
||||
async def test_http_404_raises_imdb_http_error(self, mock_async_client_cls, client):
|
||||
# Arrange
|
||||
mock_resp = _make_mock_response(404)
|
||||
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Not Found", request=MagicMock(), response=mock_resp
|
||||
)
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.get = AsyncMock(return_value=mock_resp)
|
||||
mock_async_client_cls.return_value = mock_ctx
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ImdbHttpError) as exc_info:
|
||||
await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc")
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@patch("src.components.clients.imdb_client.traced_async_client")
|
||||
async def test_http_500_raises_imdb_http_error(self, mock_async_client_cls, client):
|
||||
# Arrange
|
||||
mock_resp = _make_mock_response(500)
|
||||
mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Internal Server Error", request=MagicMock(), response=mock_resp
|
||||
)
|
||||
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.get = AsyncMock(return_value=mock_resp)
|
||||
mock_async_client_cls.return_value = mock_ctx
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ImdbHttpError) as exc_info:
|
||||
await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc")
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
@patch("src.components.clients.imdb_client.traced_async_client")
|
||||
async def test_timeout_raises_imdb_timeout_error(self, mock_async_client_cls, client):
|
||||
# Arrange
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.get = AsyncMock(side_effect=httpx.TimeoutException("timed out"))
|
||||
mock_async_client_cls.return_value = mock_ctx
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ImdbTimeoutError):
|
||||
await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
@patch("src.components.clients.imdb_client.traced_async_client")
|
||||
async def test_request_error_raises_imdb_connection_error(self, mock_async_client_cls, client):
|
||||
# Arrange
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.get = AsyncMock(side_effect=httpx.RequestError("connection refused"))
|
||||
mock_async_client_cls.return_value = mock_ctx
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ImdbConnectionError):
|
||||
await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
@patch("src.components.clients.imdb_client.traced_async_client")
|
||||
async def test_unexpected_exception_raises_imdb_client_error(self, mock_async_client_cls, client):
|
||||
# Arrange
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.get = AsyncMock(side_effect=RuntimeError("unexpected"))
|
||||
mock_async_client_cls.return_value = mock_ctx
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ImdbClientError):
|
||||
await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
class TestImdbClientRetry:
|
||||
|
||||
@patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data")
|
||||
async def test_retry_on_connection_error_success_second_attempt(self, mock_get_data, client):
|
||||
# Arrange
|
||||
mock_get_data.side_effect = [
|
||||
ImdbConnectionError("http://test", "timeout"),
|
||||
MagicMock(plan={"name": "TIM Black"}, statusType="Active", statusDescription="Full Access")
|
||||
]
|
||||
|
||||
# Act
|
||||
with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()):
|
||||
result = await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
# Assert
|
||||
assert result.plan == {"name": "TIM Black"}
|
||||
assert mock_get_data.call_count == 2
|
||||
|
||||
@patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data")
|
||||
async def test_retry_on_429_success_second_attempt(self, mock_get_data, client):
|
||||
# Arrange
|
||||
mock_get_data.side_effect = [
|
||||
ImdbHttpError(429, "Too Many Requests"),
|
||||
MagicMock(plan={"name": "TIM Black"}, statusType="Active", statusDescription="Full Access")
|
||||
]
|
||||
|
||||
# Act
|
||||
with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()):
|
||||
result = await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
# Assert
|
||||
assert result.plan == {"name": "TIM Black"}
|
||||
assert mock_get_data.call_count == 2
|
||||
|
||||
@patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data")
|
||||
async def test_no_retry_on_401_error(self, mock_get_data, client):
|
||||
# Arrange
|
||||
mock_get_data.side_effect = ImdbHttpError(401, "Unauthorized")
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ImdbHttpError) as exc_info:
|
||||
await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc")
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert mock_get_data.call_count == 1
|
||||
|
||||
@patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data")
|
||||
async def test_exhaust_retries_raises_exceeded_error(self, mock_get_data, client):
|
||||
# Arrange
|
||||
last_error = ImdbTimeoutError("http://test", 5.0)
|
||||
mock_get_data.side_effect = last_error
|
||||
|
||||
# Act & Assert
|
||||
from src.components.clients.exceptions.imdb_exceptions import ImdbExceededRetriesError
|
||||
with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()):
|
||||
with pytest.raises(ImdbExceededRetriesError) as exc_info:
|
||||
await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc", max_retries=2)
|
||||
|
||||
assert exc_info.value.attempts == 2
|
||||
assert mock_get_data.call_count == 3 # Initial + 2 retries
|
||||
187
tests_original_develop/unit/test_imdb_enrichment.py
Normal file
187
tests_original_develop/unit/test_imdb_enrichment.py
Normal file
@@ -0,0 +1,187 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from src.agent.nodes.imdb_enrichment_node import imdb_enrich_ticket, should_continue
|
||||
from src.components.clients.exceptions.imdb_exceptions import (
|
||||
ImdbClientError,
|
||||
ImdbHttpError,
|
||||
ImdbTimeoutError,
|
||||
ImdbConnectionError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_state(request_context=None, session_id="sess-123"):
|
||||
"""Build a minimal AgentState for tests."""
|
||||
return {
|
||||
"messages": [],
|
||||
"current_step": "start",
|
||||
"iteration_count": 0,
|
||||
"tool_results": None,
|
||||
"final_response": None,
|
||||
"session_id": session_id,
|
||||
"metadata": {"request_context": request_context} if request_context else {},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _valid_context(cpf_cnpj="12345678901", msisdn="5511999999999"):
|
||||
return {
|
||||
"customer": {
|
||||
"cpfCnpj": cpf_cnpj,
|
||||
"msisdn": msisdn,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
MOCK_IMDB_RESPONSE = {
|
||||
"plan": "TIM Black",
|
||||
"statusType": "Active",
|
||||
"statusDescription": "Acesso Full",
|
||||
"socialSecNo": "06252533106",
|
||||
}
|
||||
|
||||
IMDB_CLIENT_PATH = "src.agent.nodes.imdb_enrichment_node.ImdbClient"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
class TestImdbEnrichmentNode:
|
||||
|
||||
# --- Happy path ---------------------------------------------------------
|
||||
|
||||
@patch(IMDB_CLIENT_PATH)
|
||||
async def test_imdb_enrichment_node_success(self, mock_client_cls):
|
||||
"""Node should store imdb_access_data in context and set step to 'imdb_enrichment_completed'."""
|
||||
# Arrange
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_imdb_access_data_with_retry.return_value = AsyncMock(model_dump=lambda: MOCK_IMDB_RESPONSE)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
state = make_state(request_context=_valid_context())
|
||||
|
||||
# Act
|
||||
final_state = await imdb_enrich_ticket(state)
|
||||
|
||||
# Assert
|
||||
assert final_state["current_step"] == "imdb_enrichment_completed"
|
||||
assert final_state["error"] is None
|
||||
|
||||
imdb_data = final_state["metadata"]["request_context"]["imdb_access_data"]
|
||||
assert imdb_data["plan"] == "TIM Black"
|
||||
assert imdb_data["statusType"] == "Active"
|
||||
assert imdb_data["cpf_cnpj"] == "12345678901"
|
||||
assert imdb_data["socialSecNo"] == "06252533106"
|
||||
mock_client.get_imdb_access_data_with_retry.assert_called_once()
|
||||
|
||||
# --- Validation errors --------------------------------------------------
|
||||
|
||||
async def test_imdb_enrichment_node_missing_context(self):
|
||||
"""No request_context → ValidationError."""
|
||||
state = make_state(request_context=None)
|
||||
final_state = await imdb_enrich_ticket(state)
|
||||
assert final_state["error"]["type"] == "ValidationError"
|
||||
assert final_state["error"]["step"] == "imdb_enrichment_failed"
|
||||
|
||||
async def test_imdb_enrichment_node_missing_msisdn(self):
|
||||
"""msisdn=None → Redirect (smart human handling)."""
|
||||
state = make_state(request_context=_valid_context(msisdn=None))
|
||||
final_state = await imdb_enrich_ticket(state)
|
||||
assert final_state["current_step"] == "imdb_enrichment_completed"
|
||||
assert final_state["metadata"]["request_context"]["smart_human_handling"] is True
|
||||
|
||||
# --- Client errors ------------------------------------------------------
|
||||
|
||||
@patch(IMDB_CLIENT_PATH)
|
||||
async def test_imdb_enrichment_node_http_error(self, mock_client_cls):
|
||||
"""ImdbHttpError → error.type == 'ImdbHttpError', step == 'imdb_enrichment_failed'."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_imdb_access_data_with_retry.side_effect = ImdbHttpError(404, "Not Found")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context()))
|
||||
|
||||
assert final_state["error"]["type"] == "ImdbHttpError"
|
||||
assert final_state["current_step"] == "imdb_enrichment_failed"
|
||||
|
||||
@patch(IMDB_CLIENT_PATH)
|
||||
async def test_imdb_enrichment_node_timeout_error(self, mock_client_cls):
|
||||
"""ImdbTimeoutError → error.type == 'ImdbTimeoutError'."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_imdb_access_data_with_retry.side_effect = ImdbTimeoutError("url", 5.0)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context()))
|
||||
|
||||
assert final_state["error"]["type"] == "ImdbTimeoutError"
|
||||
assert final_state["current_step"] == "imdb_enrichment_failed"
|
||||
|
||||
@patch(IMDB_CLIENT_PATH)
|
||||
async def test_imdb_enrichment_node_connection_error(self, mock_client_cls):
|
||||
"""ImdbConnectionError → error.type == 'ImdbConnectionError'."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_imdb_access_data_with_retry.side_effect = ImdbConnectionError("url", "Connection refused")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context()))
|
||||
|
||||
assert final_state["error"]["type"] == "ImdbConnectionError"
|
||||
assert final_state["current_step"] == "imdb_enrichment_failed"
|
||||
|
||||
@patch(IMDB_CLIENT_PATH)
|
||||
async def test_imdb_enrichment_node_no_content_response(self, mock_client_cls):
|
||||
"""204 No Content (None response) → step == 'imdb_enrichment_completed'."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_imdb_access_data_with_retry.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context()))
|
||||
|
||||
assert final_state["current_step"] == "imdb_enrichment_completed"
|
||||
assert final_state["error"] is None
|
||||
|
||||
context = final_state["metadata"]["request_context"]
|
||||
assert "imdb_access_data" in context
|
||||
assert context["imdb_access_data"]["plan"] is None
|
||||
assert context["imdb_access_data"]["status_code"] == 204
|
||||
|
||||
@patch(IMDB_CLIENT_PATH)
|
||||
async def test_imdb_enrichment_node_generic_client_error(self, mock_client_cls):
|
||||
"""Generic ImdbClientError → error.type == 'ImdbClientError'."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_imdb_access_data_with_retry.side_effect = ImdbClientError("Unexpected error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
final_state = await imdb_enrich_ticket(make_state(request_context=_valid_context()))
|
||||
|
||||
assert final_state["error"]["type"] == "ImdbClientError"
|
||||
assert final_state["current_step"] == "imdb_enrichment_failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestShouldContinue:
|
||||
|
||||
def test_should_continue_when_imdb_enrichment_completed(self):
|
||||
state = make_state()
|
||||
state["current_step"] = "imdb_enrichment_completed"
|
||||
assert should_continue(state) == "continue"
|
||||
|
||||
def test_should_end_when_imdb_enrichment_failed(self):
|
||||
state = make_state()
|
||||
state["current_step"] = "imdb_enrichment_failed"
|
||||
assert should_continue(state) == "failed"
|
||||
|
||||
def test_should_end_on_unknown_step(self):
|
||||
state = make_state()
|
||||
state["current_step"] = "some_other_step"
|
||||
assert should_continue(state) == "failed"
|
||||
238
tests_original_develop/unit/test_knowledge_base_enrichment.py
Normal file
238
tests_original_develop/unit/test_knowledge_base_enrichment.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from src.agent.nodes.knowledge_base_enrichment_node import (
|
||||
enrich_with_knowledge_base,
|
||||
_build_query_payload,
|
||||
_extract_plan_info,
|
||||
)
|
||||
from src.components.clients.exceptions.tais_kb_exceptions import TaisKbClientError
|
||||
|
||||
|
||||
TAIS_CLIENT_PATH = "src.agent.nodes.knowledge_base_enrichment_node.TaisKbClient"
|
||||
|
||||
|
||||
def make_state(request_context=None, session_id="sess-kb-1"):
|
||||
return {
|
||||
"messages": [],
|
||||
"current_step": "start",
|
||||
"iteration_count": 0,
|
||||
"tool_results": None,
|
||||
"final_response": None,
|
||||
"session_id": session_id,
|
||||
"metadata": {"request_context": request_context} if request_context else {"request_context": {}},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _valid_context():
|
||||
return {
|
||||
"speech_analytics": {
|
||||
"reclamacao_resumo": "Cliente reclama de cobrança indevida",
|
||||
"motivo_reclamacao": "Cobrança",
|
||||
"submotivo_reclamacao": "Fatura",
|
||||
"causa_raiz": "Erro de cadastro",
|
||||
},
|
||||
"imdb_access_data": {
|
||||
"plan": {"name": "TIM Pré Top", "Type": "Pré"},
|
||||
},
|
||||
"complaint": {
|
||||
"inputChannel": "anatel",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
MOCK_DB_ROWS = [
|
||||
{
|
||||
"title_proc": "Procedimento Cobrança Pré-Pago",
|
||||
"grp_content_chunks": "## Descrição - tratativa para cobrança",
|
||||
"distance": 0.21,
|
||||
},
|
||||
{
|
||||
"title_proc": "Procedimento Cobrança Pré-Pago", # duplicate, must be deduped
|
||||
"grp_content_chunks": "outro chunk do mesmo doc",
|
||||
"distance": 0.25,
|
||||
},
|
||||
{
|
||||
"title_proc": "Procedimento Recarga",
|
||||
"grp_content_chunks": "## Descrição - recarga",
|
||||
"distance": 0.30,
|
||||
},
|
||||
{
|
||||
"title_proc": "Procedimento Limite",
|
||||
"grp_content_chunks": "## Descrição - limite",
|
||||
"distance": 0.35,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExtractPlanInfo:
|
||||
def test_plan_dict_with_name_and_type(self):
|
||||
plan_text, plan_type, commercial_name = _extract_plan_info({"plan": {"name": "TIM Pré Top", "Type": "Pré"}})
|
||||
assert plan_text == "TIM Pré Top"
|
||||
assert plan_type == "Pré"
|
||||
assert commercial_name == "TIM Pré Top"
|
||||
|
||||
def test_plan_string(self):
|
||||
plan_text, plan_type, commercial_name = _extract_plan_info({"plan": "Controle"})
|
||||
assert plan_text == "Controle"
|
||||
assert plan_type == "Controle"
|
||||
assert commercial_name is None
|
||||
|
||||
def test_plan_missing(self):
|
||||
assert _extract_plan_info({}) == (None, None, None)
|
||||
assert _extract_plan_info({"plan": None}) == (None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildQueryPayload:
|
||||
def test_full_payload(self):
|
||||
query, segment, sub_segment = _build_query_payload(_valid_context())
|
||||
assert "Cliente reclama de cobrança indevida" in query
|
||||
|
||||
def test_empty_speech_returns_empty_query(self):
|
||||
ctx = {"speech_analytics": {}, "imdb_access_data": {}, "complaint": {}}
|
||||
query, segment, sub_segment = _build_query_payload(ctx)
|
||||
assert query == ""
|
||||
assert segment is None
|
||||
assert sub_segment is None
|
||||
|
||||
def test_partial_speech(self):
|
||||
ctx = {
|
||||
"speech_analytics": {"reclamacao_resumo": "X"},
|
||||
"imdb_access_data": {},
|
||||
"complaint": {"inputChannel": "anatel"},
|
||||
}
|
||||
query, _, _ = _build_query_payload(ctx)
|
||||
assert "X" in query
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
class TestKnowledgeBaseEnrichmentNode:
|
||||
|
||||
@patch(TAIS_CLIENT_PATH)
|
||||
async def test_happy_path_returns_top_k_unique_documents(self, mock_client_cls):
|
||||
mock_client = AsyncMock()
|
||||
# client already deduplicates internally; simulate that here
|
||||
mock_client.search_documents.return_value = {
|
||||
"sql": "SELECT * FROM table",
|
||||
"results": [
|
||||
MOCK_DB_ROWS[0],
|
||||
MOCK_DB_ROWS[2],
|
||||
MOCK_DB_ROWS[3],
|
||||
],
|
||||
}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
state = make_state(request_context=_valid_context())
|
||||
final_state = await enrich_with_knowledge_base(state)
|
||||
|
||||
assert final_state["current_step"] == "knowledge_base_enrichment"
|
||||
rd = final_state["metadata"]["request_context"]["relevant_documents"]
|
||||
assert "query" in rd
|
||||
assert len(rd["documents"]) == 3
|
||||
assert rd["documents"][0]["title"] == "Procedimento Cobrança Pré-Pago"
|
||||
assert rd["documents"][0]["distance"] == 0.21
|
||||
assert "chunk" in rd["documents"][0]
|
||||
assert "message" not in rd
|
||||
assert "_error" not in rd
|
||||
|
||||
@patch(TAIS_CLIENT_PATH)
|
||||
async def test_empty_query_payload_skips_client_and_returns_not_found(self, mock_client_cls):
|
||||
state = make_state(request_context={
|
||||
"speech_analytics": {},
|
||||
"imdb_access_data": {},
|
||||
"complaint": {},
|
||||
})
|
||||
|
||||
final_state = await enrich_with_knowledge_base(state)
|
||||
|
||||
assert final_state["current_step"] == "knowledge_base_enrichment"
|
||||
rd = final_state["metadata"]["request_context"]["relevant_documents"]
|
||||
assert rd["documents"] == []
|
||||
assert rd["message"] == "Procedimentos não encontrado"
|
||||
mock_client_cls.assert_not_called()
|
||||
|
||||
@patch(TAIS_CLIENT_PATH)
|
||||
async def test_client_error_does_not_break_flow(self, mock_client_cls):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.search_documents.side_effect = TaisKbClientError("DB unreachable")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
state = make_state(request_context=_valid_context())
|
||||
final_state = await enrich_with_knowledge_base(state)
|
||||
|
||||
assert final_state["current_step"] == "knowledge_base_enrichment_unavailable"
|
||||
assert final_state.get("error") is None
|
||||
rd = final_state["metadata"]["request_context"]["relevant_documents"]
|
||||
assert rd["documents"] == []
|
||||
assert rd["message"] == "Indisponível consulta de Procedimento - realizar consulta manualmente"
|
||||
assert "DB unreachable" in rd["_error"]
|
||||
|
||||
@patch(TAIS_CLIENT_PATH)
|
||||
async def test_unexpected_exception_does_not_break_flow(self, mock_client_cls):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.search_documents.side_effect = RuntimeError("boom")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
state = make_state(request_context=_valid_context())
|
||||
final_state = await enrich_with_knowledge_base(state)
|
||||
|
||||
assert final_state["current_step"] == "knowledge_base_enrichment_unavailable"
|
||||
assert final_state.get("error") is None
|
||||
rd = final_state["metadata"]["request_context"]["relevant_documents"]
|
||||
assert rd["message"] == "Indisponível consulta de Procedimento - realizar consulta manualmente"
|
||||
assert "boom" in rd["_error"]
|
||||
|
||||
@patch(TAIS_CLIENT_PATH)
|
||||
async def test_client_returns_zero_documents(self, mock_client_cls):
|
||||
mock_client = AsyncMock()
|
||||
mock_client.search_documents.return_value = {
|
||||
"sql": "SELECT * FROM table",
|
||||
"results": [],
|
||||
}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
state = make_state(request_context=_valid_context())
|
||||
final_state = await enrich_with_knowledge_base(state)
|
||||
|
||||
rd = final_state["metadata"]["request_context"]["relevant_documents"]
|
||||
assert rd["documents"] == []
|
||||
assert rd["message"] == "Procedimentos não encontrado"
|
||||
assert "_error" not in rd
|
||||
|
||||
@patch(TAIS_CLIENT_PATH)
|
||||
async def test_cache_hit_skips_client_call(self, mock_client_cls):
|
||||
cached = {
|
||||
"query": "old query",
|
||||
"documents": [{"title": "T", "chunk": "C", "distance": 0.1}],
|
||||
}
|
||||
ctx = _valid_context()
|
||||
ctx["relevant_documents"] = cached
|
||||
|
||||
state = make_state(request_context=ctx)
|
||||
final_state = await enrich_with_knowledge_base(state)
|
||||
|
||||
assert final_state["current_step"] == "knowledge_base_enrichment"
|
||||
assert final_state["metadata"]["request_context"]["relevant_documents"] is cached
|
||||
mock_client_cls.assert_not_called()
|
||||
|
||||
@patch(TAIS_CLIENT_PATH)
|
||||
async def test_search_documents_called_with_correct_params(self, mock_client_cls):
|
||||
from src.components.clients.tais_kb_client import Product
|
||||
mock_client = AsyncMock()
|
||||
mock_client.search_documents.return_value = {
|
||||
"sql": "SELECT * FROM table",
|
||||
"results": [],
|
||||
}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
state = make_state(request_context=_valid_context())
|
||||
await enrich_with_knowledge_base(state)
|
||||
|
||||
from src.core.config import settings
|
||||
call_kwargs = mock_client.search_documents.call_args.kwargs
|
||||
assert call_kwargs["top_k"] == settings.TAIS_TOP_K
|
||||
assert call_kwargs["product"] == Product.MOVEL
|
||||
228
tests_original_develop/unit/test_llm_factory.py
Normal file
228
tests_original_develop/unit/test_llm_factory.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Unit tests for LLM factory module.
|
||||
|
||||
Tests the creation of LLM instances from different providers
|
||||
and error handling for missing configuration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from agent_framework.components.llm.factory import LLM, call_llm_with_retry
|
||||
from agent_framework.core.config import EligibleModel
|
||||
from agent_framework.core.exceptions import ConfigurationError, LLMError
|
||||
from src.core.config import Settings
|
||||
|
||||
|
||||
class TestGetLLM:
|
||||
"""Tests for get_llm factory function."""
|
||||
|
||||
def test_get_llm_openai_success(self, monkeypatch):
|
||||
"""Test creating OpenAI LLM with valid configuration."""
|
||||
# Setup
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.components.llm.factory.settings",
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
LLM_TEMPERATURE=0.7,
|
||||
LLM_MAX_TOKENS=1000,
|
||||
OPENAI_API_KEY="test-key-123"
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 )
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, ChatOpenAI)
|
||||
assert llm.model_name == "gpt-4"
|
||||
assert llm.temperature == 0.7
|
||||
assert llm.max_tokens == 1000
|
||||
|
||||
def test_get_llm_anthropic_success(self, monkeypatch):
|
||||
"""Test creating Anthropic LLM with valid configuration."""
|
||||
# Setup
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.components.llm.factory.settings",
|
||||
Settings(
|
||||
LLM_PROVIDER="anthropic",
|
||||
LLM_MODEL="claude-3-opus-20240229",
|
||||
LLM_TEMPERATURE=0.5,
|
||||
LLM_MAX_TOKENS=2000,
|
||||
ANTHROPIC_API_KEY="test-key-456"
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 )
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, ChatAnthropic)
|
||||
assert llm.model == "claude-3-opus-20240229"
|
||||
assert llm.temperature == 0.5
|
||||
assert llm.max_tokens == 2000
|
||||
|
||||
def test_get_llm_openai_missing_api_key(self, monkeypatch):
|
||||
"""Test that missing OpenAI API key raises ConfigurationError."""
|
||||
# Setup
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.components.llm.factory.settings",
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-4",
|
||||
OPENAI_API_KEY=None
|
||||
)
|
||||
)
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(ConfigurationError) as exc_info:
|
||||
LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 )
|
||||
|
||||
assert "OPENAI_API_KEY is required" in str(exc_info.value)
|
||||
|
||||
def test_get_llm_anthropic_missing_api_key(self, monkeypatch):
|
||||
"""Test that missing Anthropic API key raises ConfigurationError."""
|
||||
# Setup
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.components.llm.factory.settings",
|
||||
Settings(
|
||||
LLM_PROVIDER="anthropic",
|
||||
LLM_MODEL="claude-3-opus-20240229",
|
||||
ANTHROPIC_API_KEY=None
|
||||
)
|
||||
)
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(ConfigurationError) as exc_info:
|
||||
LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 )
|
||||
|
||||
assert "ANTHROPIC_API_KEY is required" in str(exc_info.value)
|
||||
|
||||
def test_get_llm_unsupported_provider(self, monkeypatch):
|
||||
"""Test that unsupported provider raises ConfigurationError."""
|
||||
# Setup - need to bypass validation in Settings
|
||||
settings_mock = MagicMock()
|
||||
settings_mock.LLM_PROVIDER = "unsupported"
|
||||
settings_mock.LLM_MODEL = "some-model"
|
||||
|
||||
monkeypatch.setattr("agent_framework.components.llm.factory.settings", settings_mock)
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(ConfigurationError) as exc_info:
|
||||
LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 )
|
||||
|
||||
assert "Unsupported LLM provider" in str(exc_info.value)
|
||||
assert "unsupported" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestCallLLMWithRetry:
|
||||
"""Tests for call_llm_with_retry function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_llm_success_first_attempt(self):
|
||||
"""Test successful LLM call on first attempt."""
|
||||
# Setup
|
||||
mock_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Create async mock
|
||||
async def mock_ainvoke(msgs):
|
||||
return mock_response
|
||||
|
||||
mock_llm.ainvoke = mock_ainvoke
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
# Execute
|
||||
result = await call_llm_with_retry(mock_llm, messages)
|
||||
|
||||
# Assert
|
||||
assert result == mock_response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_llm_retry_then_success(self):
|
||||
"""Test LLM call succeeds after retry."""
|
||||
# Setup
|
||||
mock_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
|
||||
# Fail first call, succeed on second
|
||||
call_count = 0
|
||||
async def mock_ainvoke(msgs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise Exception("Temporary network error")
|
||||
return mock_response
|
||||
|
||||
mock_llm.ainvoke = mock_ainvoke
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
# Execute
|
||||
result = await call_llm_with_retry(mock_llm, messages)
|
||||
|
||||
# Assert
|
||||
assert result == mock_response
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_llm_all_retries_fail(self):
|
||||
"""Test LLM call fails after all retries exhausted."""
|
||||
# Setup
|
||||
mock_llm = MagicMock()
|
||||
|
||||
async def mock_ainvoke(msgs):
|
||||
raise Exception("Persistent error")
|
||||
|
||||
mock_llm.ainvoke = mock_ainvoke
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
# Execute & Assert
|
||||
with pytest.raises(LLMError) as exc_info:
|
||||
await call_llm_with_retry(mock_llm, messages)
|
||||
|
||||
assert "Failed to get LLM response after retries" in str(exc_info.value)
|
||||
assert "Persistent error" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestLLMFactoryIntegration:
|
||||
"""Integration tests for LLM factory with different configurations."""
|
||||
|
||||
def test_openai_with_default_max_tokens(self, monkeypatch):
|
||||
"""Test OpenAI LLM creation with default (None) max_tokens."""
|
||||
# Setup
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.components.llm.factory.settings",
|
||||
Settings(
|
||||
LLM_PROVIDER="openai",
|
||||
LLM_MODEL="gpt-3.5-turbo",
|
||||
LLM_MAX_TOKENS=None,
|
||||
OPENAI_API_KEY="test-key"
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 )
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, ChatOpenAI)
|
||||
assert llm.max_tokens is None
|
||||
|
||||
def test_case_insensitive_provider(self, monkeypatch):
|
||||
"""Test that provider name is case-insensitive."""
|
||||
# Setup
|
||||
monkeypatch.setattr(
|
||||
"agent_framework.components.llm.factory.settings",
|
||||
Settings(
|
||||
LLM_PROVIDER="openai", # lowercase
|
||||
LLM_MODEL="gpt-4",
|
||||
OPENAI_API_KEY="test-key"
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
llm = LLM(EligibleModel.bo_gptoss20b_dev, 0.7, 1024, -1 )
|
||||
|
||||
# Assert
|
||||
assert isinstance(llm, ChatOpenAI)
|
||||
50
tests_original_develop/unit/test_llm_retry.py
Normal file
50
tests_original_develop/unit/test_llm_retry.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import oci
|
||||
from src.providers.llm_provider import chat_llm_with_usage, classification_llm
|
||||
|
||||
@patch("oci.generative_ai_inference.GenerativeAiInferenceClient.chat")
|
||||
def test_chat_llm_retry_on_timeout(mock_chat):
|
||||
"""
|
||||
Testa se o chat_llm_with_usage executa retries quando recebe um ServiceError (504).
|
||||
Simula 2 falhas seguidas e sucesso na 3ª tentativa.
|
||||
"""
|
||||
# ── Montagem do Cenário ──────────────────────────────────────────────────
|
||||
# Criamos o erro que o retry deve capturar
|
||||
timeout_error = oci.exceptions.ServiceError(
|
||||
status=504,
|
||||
code="GatewayTimeout",
|
||||
message="Request timed out",
|
||||
headers={},
|
||||
operation_name="Chat"
|
||||
)
|
||||
|
||||
# Simula resposta de sucesso na 3ª chamada
|
||||
mock_response = MagicMock()
|
||||
chat_response = mock_response.data.chat_response
|
||||
|
||||
# Configuramos os valores de retorno para serem tipos primitivos (não Mocks)
|
||||
chat_response.choices = [
|
||||
MagicMock(
|
||||
message=MagicMock(content=[MagicMock(text="Sucesso após retry!")]),
|
||||
finish_reason="stop"
|
||||
)
|
||||
]
|
||||
chat_response.model = "cohere.command-r-plus"
|
||||
chat_response.usage.prompt_tokens = 10
|
||||
chat_response.usage.completion_tokens = 10
|
||||
chat_response.usage.total_tokens = 20
|
||||
|
||||
# Configuramos os side_effects: Falha, Falha, Sucesso
|
||||
mock_chat.side_effect = [timeout_error, timeout_error, mock_response]
|
||||
|
||||
# ── Execução ─────────────────────────────────────────────────────────────
|
||||
prompt = "Teste de retry"
|
||||
result = chat_llm_with_usage(classification_llm, prompt)
|
||||
|
||||
# ── Verificação ──────────────────────────────────────────────────────────
|
||||
# O resultado final deve ser o da 3ª chamada
|
||||
assert result.content == "Sucesso após retry!"
|
||||
|
||||
# Verificamos se o client.chat foi chamado exatamente 3 vezes
|
||||
assert mock_chat.call_count == 3
|
||||
420
tests_original_develop/unit/test_logging.py
Normal file
420
tests_original_develop/unit/test_logging.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
Testes unitários para o módulo de logging.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from io import StringIO
|
||||
import pytest
|
||||
|
||||
import src.core.logging as _logging_module
|
||||
from src.core.logging import (
|
||||
setup_logging,
|
||||
get_logger,
|
||||
set_request_id,
|
||||
set_session_id,
|
||||
set_user_id,
|
||||
clear_context,
|
||||
MetricsLogger,
|
||||
ContextFilter,
|
||||
_StructuredFormatter,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_logging():
|
||||
"""Reset logging configuration and singleton state after each test."""
|
||||
yield
|
||||
root = logging.getLogger()
|
||||
for handler in root.handlers[:]:
|
||||
root.removeHandler(handler)
|
||||
_logging_module._configured = False
|
||||
clear_context()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_logs():
|
||||
"""Capture log output to a string buffer"""
|
||||
log_capture = StringIO()
|
||||
handler = logging.StreamHandler(log_capture)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
|
||||
# Use simple formatter for easier testing
|
||||
formatter = logging.Formatter('%(levelname)s - %(name)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
yield log_capture
|
||||
|
||||
logger.removeHandler(handler)
|
||||
|
||||
|
||||
def test_setup_logging_creates_logger(reset_logging):
|
||||
"""Test that setup_logging creates and configures a logger"""
|
||||
logger = setup_logging()
|
||||
|
||||
assert logger is not None
|
||||
assert isinstance(logger, logging.Logger)
|
||||
assert len(logger.handlers) > 0
|
||||
|
||||
|
||||
def test_get_logger_returns_logger():
|
||||
"""Test that get_logger returns a logger with the specified name"""
|
||||
logger = get_logger("test_module")
|
||||
|
||||
assert logger is not None
|
||||
assert logger.name == "test_module"
|
||||
|
||||
|
||||
def test_context_filter_adds_request_id():
|
||||
"""Test that ContextFilter adds request_id to log records"""
|
||||
set_request_id("test-request-123")
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg="test message",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
|
||||
filter = ContextFilter()
|
||||
filter.filter(record)
|
||||
|
||||
assert hasattr(record, 'request_id')
|
||||
assert record.request_id == "test-request-123"
|
||||
|
||||
clear_context()
|
||||
|
||||
|
||||
def test_context_filter_adds_session_id():
|
||||
"""Test that ContextFilter adds session_id to log records"""
|
||||
set_session_id("session-456")
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg="test message",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
|
||||
filter = ContextFilter()
|
||||
filter.filter(record)
|
||||
|
||||
assert hasattr(record, 'session_id')
|
||||
assert record.session_id == "session-456"
|
||||
|
||||
clear_context()
|
||||
|
||||
|
||||
def test_context_filter_adds_user_id():
|
||||
"""Test that ContextFilter adds user_id to log records"""
|
||||
set_user_id("user-789")
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg="test message",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
|
||||
filter = ContextFilter()
|
||||
filter.filter(record)
|
||||
|
||||
assert hasattr(record, 'user_id')
|
||||
assert record.user_id == "user-789"
|
||||
|
||||
clear_context()
|
||||
|
||||
|
||||
def test_set_and_clear_context():
|
||||
"""Test setting and clearing context variables"""
|
||||
set_request_id("req-1")
|
||||
set_session_id("sess-1")
|
||||
set_user_id("user-1")
|
||||
|
||||
# Create a record and apply filter
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg="test",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
|
||||
filter = ContextFilter()
|
||||
filter.filter(record)
|
||||
|
||||
assert record.request_id == "req-1"
|
||||
assert record.session_id == "sess-1"
|
||||
assert record.user_id == "user-1"
|
||||
|
||||
# Clear context
|
||||
clear_context()
|
||||
|
||||
# Create new record
|
||||
record2 = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg="test",
|
||||
args=(),
|
||||
exc_info=None
|
||||
)
|
||||
|
||||
filter.filter(record2)
|
||||
|
||||
assert record2.request_id is None
|
||||
assert record2.session_id is None
|
||||
assert record2.user_id is None
|
||||
|
||||
|
||||
def test_metrics_logger_measures_time():
|
||||
"""Test that MetricsLogger correctly measures elapsed time"""
|
||||
import time
|
||||
|
||||
metrics = MetricsLogger("test_operation")
|
||||
metrics.start()
|
||||
|
||||
time.sleep(0.1) # Sleep for 100ms
|
||||
|
||||
elapsed = metrics.stop()
|
||||
|
||||
assert elapsed >= 0.1
|
||||
assert elapsed < 0.2 # Should be close to 0.1
|
||||
|
||||
|
||||
def test_metrics_logger_logs_basic_metrics(capture_logs):
|
||||
"""Test that MetricsLogger logs basic metrics"""
|
||||
logger = get_logger("test")
|
||||
metrics = MetricsLogger("test_op", logger=logger)
|
||||
|
||||
metrics.start()
|
||||
metrics.stop()
|
||||
metrics.log()
|
||||
|
||||
log_output = capture_logs.getvalue()
|
||||
assert "test_op" in log_output or "Metrics" in log_output
|
||||
|
||||
|
||||
def test_metrics_logger_logs_tokens_and_cost(capture_logs):
|
||||
"""Test that MetricsLogger logs tokens and cost"""
|
||||
logger = get_logger("test")
|
||||
metrics = MetricsLogger("llm_call", logger=logger)
|
||||
|
||||
metrics.start()
|
||||
metrics.stop()
|
||||
metrics.log(tokens=150, cost=0.003)
|
||||
|
||||
log_output = capture_logs.getvalue()
|
||||
assert "llm_call" in log_output or "Metrics" in log_output
|
||||
|
||||
|
||||
def test_metrics_logger_logs_extra_metrics(capture_logs):
|
||||
"""Test that MetricsLogger logs extra metrics"""
|
||||
logger = get_logger("test")
|
||||
metrics = MetricsLogger("custom_op", logger=logger)
|
||||
|
||||
metrics.start()
|
||||
metrics.stop()
|
||||
metrics.log(custom_metric=42, another_metric="value")
|
||||
|
||||
log_output = capture_logs.getvalue()
|
||||
assert "custom_op" in log_output or "Metrics" in log_output
|
||||
|
||||
|
||||
def test_metrics_logger_auto_stops_on_log():
|
||||
"""Test that MetricsLogger automatically stops when logging"""
|
||||
import time
|
||||
|
||||
metrics = MetricsLogger("test_op")
|
||||
metrics.start()
|
||||
|
||||
# Sleep a tiny bit to ensure some time passes
|
||||
time.sleep(0.001)
|
||||
|
||||
# Don't call stop() explicitly
|
||||
assert metrics.end_time is None
|
||||
|
||||
metrics.log()
|
||||
|
||||
# Should have stopped automatically
|
||||
assert metrics.end_time is not None
|
||||
assert metrics.elapsed_time >= 0 # Changed from > 0 to >= 0
|
||||
|
||||
|
||||
def test_logging_different_levels(capture_logs):
|
||||
"""Test logging at different levels"""
|
||||
logger = get_logger("test")
|
||||
|
||||
logger.debug("Debug message")
|
||||
logger.info("Info message")
|
||||
logger.warning("Warning message")
|
||||
logger.error("Error message")
|
||||
|
||||
log_output = capture_logs.getvalue()
|
||||
|
||||
# Depending on log level, some messages might not appear
|
||||
# But at least one should be there
|
||||
assert len(log_output) > 0
|
||||
|
||||
|
||||
def test_context_persists_across_multiple_logs(capture_logs):
|
||||
"""Test that context persists across multiple log calls"""
|
||||
set_request_id("persistent-req")
|
||||
set_session_id("persistent-sess")
|
||||
|
||||
logger = get_logger("test")
|
||||
logger.info("First log")
|
||||
logger.info("Second log")
|
||||
logger.info("Third log")
|
||||
|
||||
# Context should persist for all logs
|
||||
# This is verified by the ContextFilter being applied to all records
|
||||
|
||||
clear_context()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("level", [
|
||||
logging.DEBUG,
|
||||
logging.INFO,
|
||||
logging.WARNING,
|
||||
logging.ERROR,
|
||||
])
|
||||
def test_metrics_logger_respects_log_level(level):
|
||||
"""Test that MetricsLogger respects different log levels"""
|
||||
logger = get_logger("test")
|
||||
metrics = MetricsLogger("test_op", logger=logger)
|
||||
|
||||
metrics.start()
|
||||
metrics.stop()
|
||||
|
||||
# Should not raise an error
|
||||
metrics.log(level=level)
|
||||
|
||||
|
||||
def test_metrics_logger_elapsed_time_before_stop():
|
||||
"""Test that elapsed_time works even before stop() is called"""
|
||||
import time
|
||||
|
||||
metrics = MetricsLogger("test_op")
|
||||
metrics.start()
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
# Should return current elapsed time even without stop()
|
||||
elapsed = metrics.elapsed_time
|
||||
assert elapsed >= 0.05
|
||||
assert elapsed < 0.1
|
||||
|
||||
|
||||
def test_metrics_logger_without_start():
|
||||
"""Test that MetricsLogger handles case where start() wasn't called"""
|
||||
metrics = MetricsLogger("test_op")
|
||||
|
||||
# Don't call start()
|
||||
elapsed = metrics.elapsed_time
|
||||
|
||||
assert elapsed == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correlation fields — session_id, user_id, request_id must always be present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_record(msg: str = "test") -> logging.LogRecord:
|
||||
record = logging.LogRecord(
|
||||
name="test", level=logging.INFO, pathname="",
|
||||
lineno=0, msg=msg, args=(), exc_info=None,
|
||||
)
|
||||
ContextFilter().filter(record)
|
||||
return record
|
||||
|
||||
|
||||
def test_json_always_has_correlation_fields_when_context_empty():
|
||||
"""JSON output must contain request_id/session_id/user_id even when None."""
|
||||
clear_context()
|
||||
formatter = _StructuredFormatter(use_json=True)
|
||||
output = formatter.format(_make_record())
|
||||
obj = json.loads(output)
|
||||
|
||||
assert "request_id" in obj
|
||||
assert "session_id" in obj
|
||||
assert "user_id" in obj
|
||||
assert obj["request_id"] is None
|
||||
assert obj["session_id"] is None
|
||||
assert obj["user_id"] is None
|
||||
|
||||
|
||||
def test_json_correlation_fields_carry_set_values():
|
||||
"""JSON output must carry the values set via set_* helpers."""
|
||||
set_request_id("req-abc")
|
||||
set_session_id("sess-xyz")
|
||||
set_user_id("user-123")
|
||||
|
||||
formatter = _StructuredFormatter(use_json=True)
|
||||
output = formatter.format(_make_record())
|
||||
obj = json.loads(output)
|
||||
|
||||
assert obj["request_id"] == "req-abc"
|
||||
assert obj["session_id"] == "sess-xyz"
|
||||
assert obj["user_id"] == "user-123"
|
||||
|
||||
clear_context()
|
||||
|
||||
|
||||
def test_text_always_has_correlation_fields_when_context_empty():
|
||||
"""Text output must contain the three correlation keys even when None."""
|
||||
clear_context()
|
||||
formatter = _StructuredFormatter(use_json=False)
|
||||
output = formatter.format(_make_record())
|
||||
|
||||
assert "request_id=None" in output
|
||||
assert "session_id=None" in output
|
||||
assert "user_id=None" in output
|
||||
|
||||
|
||||
def test_text_correlation_fields_carry_set_values():
|
||||
"""Text output must carry the values set via set_* helpers."""
|
||||
set_request_id("req-t1")
|
||||
set_session_id("sess-t1")
|
||||
set_user_id("user-t1")
|
||||
|
||||
formatter = _StructuredFormatter(use_json=False)
|
||||
output = formatter.format(_make_record())
|
||||
|
||||
assert "request_id=req-t1" in output
|
||||
assert "session_id=sess-t1" in output
|
||||
assert "user_id=user-t1" in output
|
||||
|
||||
clear_context()
|
||||
|
||||
|
||||
def test_json_correlation_fields_reset_after_clear_context():
|
||||
"""After clear_context, correlation fields revert to None in JSON."""
|
||||
set_request_id("req-temp")
|
||||
set_session_id("sess-temp")
|
||||
set_user_id("user-temp")
|
||||
clear_context()
|
||||
|
||||
formatter = _StructuredFormatter(use_json=True)
|
||||
output = formatter.format(_make_record())
|
||||
obj = json.loads(output)
|
||||
|
||||
assert obj["request_id"] is None
|
||||
assert obj["session_id"] is None
|
||||
assert obj["user_id"] is None
|
||||
164
tests_original_develop/unit/test_siebel_classification_node.py
Normal file
164
tests_original_develop/unit/test_siebel_classification_node.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Unit tests for siebel_classification_node."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from src.agent.nodes import siebel_classification_node
|
||||
from src.agent.nodes.siebel_classification_node import _classify_call, _CLASSIFICATION_REASONS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_state(request_context: dict = None) -> dict:
|
||||
"""Build a minimal AgentState for testing."""
|
||||
return {
|
||||
"messages": [HumanMessage(content="test")],
|
||||
"current_step": "enriched",
|
||||
"iteration_count": 1,
|
||||
"tool_results": None,
|
||||
"final_response": None,
|
||||
"session_id": "test-session",
|
||||
"metadata": {
|
||||
"request_context": request_context or {
|
||||
"customer": {"cpf_cnpj": "06252533106", "claimed_msisdn": "62981152324"},
|
||||
"channel": "Anatel",
|
||||
"notes": "test note",
|
||||
}
|
||||
},
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _classify_call (pure-function tests — no state, no mocks needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_call_default_returns_fallback():
|
||||
"""When random_choice is False, always returns 'fallback'."""
|
||||
for _ in range(5):
|
||||
assert _classify_call(random_choice=False) == "fallback"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classify_call_random_returns_valid_classification():
|
||||
"""When random_choice is True, returns one of the expected classifications."""
|
||||
valid = set(_CLASSIFICATION_REASONS.keys())
|
||||
for _ in range(20):
|
||||
result = _classify_call(random_choice=True)
|
||||
assert result in valid, f"Unexpected classification: {result}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_classification_reasons_has_required_keys():
|
||||
"""Every entry in _CLASSIFICATION_REASONS must have reason1, reason2, reason3."""
|
||||
for classification, reasons in _CLASSIFICATION_REASONS.items():
|
||||
for key in ("reason1", "reason2", "reason3"):
|
||||
assert key in reasons, (
|
||||
f"'{classification}' is missing '{key}' in _CLASSIFICATION_REASONS"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process() — node tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_sets_current_step_to_classified():
|
||||
"""After process(), current_step must be 'ticket_classified'."""
|
||||
state = _make_state()
|
||||
result = await siebel_classification_node.process(state)
|
||||
assert result["current_step"] == "ticket_classified"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_writes_classification_to_context():
|
||||
"""process() must write 'classification' key into request_context."""
|
||||
state = _make_state()
|
||||
result = await siebel_classification_node.process(state)
|
||||
context = result["metadata"]["request_context"]
|
||||
assert "classification" in context
|
||||
assert context["classification"] in _CLASSIFICATION_REASONS
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_writes_reasons_to_context():
|
||||
"""process() must write reason1, reason2 and reason3 into request_context."""
|
||||
state = _make_state()
|
||||
result = await siebel_classification_node.process(state)
|
||||
context = result["metadata"]["request_context"]
|
||||
for key in ("reason1", "reason2", "reason3"):
|
||||
assert key in context
|
||||
assert isinstance(context[key], str)
|
||||
assert context[key] # non-empty
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_preserves_existing_context_fields():
|
||||
"""process() must not remove pre-existing context fields."""
|
||||
original_ctx = {
|
||||
"customer": {"cpf_cnpj": "123", "claimed_msisdn": "456"},
|
||||
"channel": "Anatel",
|
||||
"notes": "important note",
|
||||
}
|
||||
state = _make_state(request_context=original_ctx)
|
||||
result = await siebel_classification_node.process(state)
|
||||
ctx = result["metadata"]["request_context"]
|
||||
|
||||
assert ctx.get("customer") == original_ctx["customer"]
|
||||
assert ctx.get("channel") == original_ctx["channel"]
|
||||
assert ctx.get("notes") == original_ctx["notes"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_reasons_match_classification():
|
||||
"""The reasons written to context must match the _CLASSIFICATION_REASONS dict."""
|
||||
with patch.object(siebel_classification_node, "_classify_call", return_value="tratamento"):
|
||||
state = _make_state()
|
||||
result = await siebel_classification_node.process(state)
|
||||
|
||||
ctx = result["metadata"]["request_context"]
|
||||
expected = _CLASSIFICATION_REASONS["tratamento"]
|
||||
assert ctx["reason1"] == expected["reason1"]
|
||||
assert ctx["reason2"] == expected["reason2"]
|
||||
assert ctx["reason3"] == expected["reason3"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("classification", ["tratamento", "fallback", "ancelado"])
|
||||
async def test_process_all_classifications(classification: str):
|
||||
"""process() must handle every known classification correctly."""
|
||||
with patch.object(siebel_classification_node, "_classify_call", return_value=classification):
|
||||
state = _make_state()
|
||||
result = await siebel_classification_node.process(state)
|
||||
|
||||
ctx = result["metadata"]["request_context"]
|
||||
assert ctx["classification"] == classification
|
||||
expected = _CLASSIFICATION_REASONS[classification]
|
||||
assert ctx["reason1"] == expected["reason1"]
|
||||
assert ctx["reason2"] == expected["reason2"]
|
||||
assert ctx["reason3"] == expected["reason3"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_fallback_used_for_unknown_classification():
|
||||
"""If _classify_call returns an unknown value, fallback reasons should be used."""
|
||||
with patch.object(siebel_classification_node, "_classify_call", return_value="unknown_type"):
|
||||
state = _make_state()
|
||||
result = await siebel_classification_node.process(state)
|
||||
|
||||
ctx = result["metadata"]["request_context"]
|
||||
expected = _CLASSIFICATION_REASONS["fallback"]
|
||||
assert ctx["reason1"] == expected["reason1"]
|
||||
assert ctx["reason2"] == expected["reason2"]
|
||||
assert ctx["reason3"] == expected["reason3"]
|
||||
389
tests_original_develop/unit/test_siebel_client.py
Normal file
389
tests_original_develop/unit/test_siebel_client.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Unit tests for SiebelClient."""
|
||||
|
||||
import base64
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from src.components.clients.siebel_client import SiebelClient
|
||||
from src.components.clients.exceptions.siebel_exceptions import (
|
||||
SiebelClientError,
|
||||
SiebelHttpError,
|
||||
SiebelConnectionError,
|
||||
SiebelTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLE_PAYLOAD = {
|
||||
"channel": "Anatel",
|
||||
"socialSecNo": "06252533106",
|
||||
"assetId": "62981152324",
|
||||
"serviceRequest": {
|
||||
"type": "0119",
|
||||
"userId": "SADMIN",
|
||||
"reason1": "Processo Interno",
|
||||
"reason2": "Turbina",
|
||||
"reason3": "Internalização",
|
||||
"status": "Encaminhado",
|
||||
"notes": "Teste",
|
||||
},
|
||||
"interaction": {
|
||||
"source": "Cliente",
|
||||
"requestFlag": False,
|
||||
"directionContact": "FROM-CLIENT",
|
||||
"status": "OPENED",
|
||||
},
|
||||
}
|
||||
|
||||
_SAMPLE_RESPONSE = {"interactionProtocol": "2026000099999"}
|
||||
|
||||
|
||||
def _make_client(**kwargs) -> SiebelClient:
|
||||
"""Instantiate SiebelClient with test defaults using patched settings."""
|
||||
with patch("src.components.clients.siebel_client.settings") as mock_settings:
|
||||
mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com"
|
||||
mock_settings.SIEBEL_API_TIMEOUT = 30
|
||||
mock_settings.VERIFY_SSL = False
|
||||
mock_settings.SIEBEL_API_USERNAME = "testuser"
|
||||
mock_settings.SIEBEL_API_PASSWORD = "testpass"
|
||||
mock_settings.SIEBEL_API_CLIENT_ID = "test-client-id"
|
||||
mock_settings.LOG_LEVEL = "INFO"
|
||||
mock_settings.LOG_FORMAT = "text"
|
||||
return SiebelClient(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_client_uses_settings_defaults():
|
||||
"""When no args are given, client falls back to settings values."""
|
||||
with patch("src.components.clients.siebel_client.settings") as mock_settings:
|
||||
mock_settings.SIEBEL_API_HOST = "https://default.example.com"
|
||||
mock_settings.SIEBEL_API_TIMEOUT = 60
|
||||
mock_settings.VERIFY_SSL = True
|
||||
mock_settings.SIEBEL_API_USERNAME = "user"
|
||||
mock_settings.SIEBEL_API_PASSWORD = "pass"
|
||||
mock_settings.SIEBEL_API_CLIENT_ID = "cid"
|
||||
mock_settings.LOG_LEVEL = "INFO"
|
||||
mock_settings.LOG_FORMAT = "text"
|
||||
client = SiebelClient()
|
||||
|
||||
assert client.base_url == "https://default.example.com"
|
||||
assert client.timeout == 60
|
||||
assert client.verify_ssl is True
|
||||
assert client.username == "user"
|
||||
assert client.password == "pass"
|
||||
assert client.client_id == "cid"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_client_explicit_args_override_settings():
|
||||
"""Explicit constructor arguments must take precedence over settings."""
|
||||
client = _make_client(
|
||||
base_url="https://override.example.com",
|
||||
timeout=10,
|
||||
verify_ssl=True,
|
||||
username="override_user",
|
||||
password="override_pass",
|
||||
client_id="override_cid",
|
||||
)
|
||||
assert client.base_url == "https://override.example.com"
|
||||
assert client.timeout == 10
|
||||
assert client.verify_ssl is True
|
||||
assert client.username == "override_user"
|
||||
assert client.password == "override_pass"
|
||||
assert client.client_id == "override_cid"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_client_accepts_verify_ssl_false_explicitly():
|
||||
"""verify_ssl=False must not be overridden by settings (falsy-value bug guard)."""
|
||||
with patch("src.components.clients.siebel_client.settings") as mock_settings:
|
||||
mock_settings.SIEBEL_API_HOST = "https://x.com"
|
||||
mock_settings.SIEBEL_API_TIMEOUT = 30
|
||||
mock_settings.VERIFY_SSL = True # settings say True
|
||||
mock_settings.SIEBEL_API_USERNAME = "u"
|
||||
mock_settings.SIEBEL_API_PASSWORD = "p"
|
||||
mock_settings.SIEBEL_API_CLIENT_ID = "c"
|
||||
mock_settings.LOG_LEVEL = "INFO"
|
||||
mock_settings.LOG_FORMAT = "text"
|
||||
client = SiebelClient(verify_ssl=False) # explicit False must win
|
||||
|
||||
assert client.verify_ssl is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_client_accepts_timeout_zero_explicitly():
|
||||
"""timeout=0 must not be overridden by settings (falsy-value bug guard)."""
|
||||
with patch("src.components.clients.siebel_client.settings") as mock_settings:
|
||||
mock_settings.SIEBEL_API_HOST = "https://x.com"
|
||||
mock_settings.SIEBEL_API_TIMEOUT = 30 # settings say 30
|
||||
mock_settings.VERIFY_SSL = False
|
||||
mock_settings.SIEBEL_API_USERNAME = "u"
|
||||
mock_settings.SIEBEL_API_PASSWORD = "p"
|
||||
mock_settings.SIEBEL_API_CLIENT_ID = "c"
|
||||
mock_settings.LOG_LEVEL = "INFO"
|
||||
mock_settings.LOG_FORMAT = "text"
|
||||
client = SiebelClient(timeout=0) # explicit 0 must win
|
||||
|
||||
assert client.timeout == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_auth_header
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_auth_header_returns_basic_header():
|
||||
"""_get_auth_header must return a valid Basic Auth header."""
|
||||
client = _make_client(username="user", password="pass")
|
||||
header = client._get_auth_header()
|
||||
|
||||
expected = "Basic " + base64.b64encode(b"user:pass").decode()
|
||||
assert header == expected
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_auth_header_returns_empty_when_no_credentials():
|
||||
"""_get_auth_header must return '' when username and password are absent from settings."""
|
||||
with patch("src.components.clients.siebel_client.settings") as mock_settings:
|
||||
mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com"
|
||||
mock_settings.SIEBEL_API_TIMEOUT = 30
|
||||
mock_settings.VERIFY_SSL = False
|
||||
mock_settings.SIEBEL_API_USERNAME = ""
|
||||
mock_settings.SIEBEL_API_PASSWORD = ""
|
||||
mock_settings.SIEBEL_API_CLIENT_ID = ""
|
||||
mock_settings.LOG_LEVEL = "INFO"
|
||||
mock_settings.LOG_FORMAT = "text"
|
||||
client = SiebelClient()
|
||||
|
||||
assert client._get_auth_header() == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_auth_header_returns_empty_when_password_missing():
|
||||
"""_get_auth_header must return '' when only password is absent from settings."""
|
||||
with patch("src.components.clients.siebel_client.settings") as mock_settings:
|
||||
mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com"
|
||||
mock_settings.SIEBEL_API_TIMEOUT = 30
|
||||
mock_settings.VERIFY_SSL = False
|
||||
mock_settings.SIEBEL_API_USERNAME = "user"
|
||||
mock_settings.SIEBEL_API_PASSWORD = ""
|
||||
mock_settings.SIEBEL_API_CLIENT_ID = ""
|
||||
mock_settings.LOG_LEVEL = "INFO"
|
||||
mock_settings.LOG_FORMAT = "text"
|
||||
client = SiebelClient()
|
||||
|
||||
assert client._get_auth_header() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_service_request — happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_returns_json_response():
|
||||
"""On HTTP 200, open_service_request must return the parsed JSON body."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = _SAMPLE_RESPONSE
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
client = _make_client()
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
result = await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
assert result == _SAMPLE_RESPONSE
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_sends_authorization_header():
|
||||
"""Request must include the Authorization header when credentials are set."""
|
||||
captured_headers = {}
|
||||
|
||||
async def fake_post(url, json, headers):
|
||||
captured_headers.update(headers)
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = _SAMPLE_RESPONSE
|
||||
return mock_response
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = fake_post
|
||||
|
||||
client = _make_client(username="user", password="pass", client_id="cid")
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
assert "Authorization" in captured_headers
|
||||
assert captured_headers["Authorization"].startswith("Basic ")
|
||||
assert captured_headers["clientId"] == "cid"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_no_auth_header_when_no_credentials():
|
||||
"""When settings have no credentials, Authorization header must not be sent."""
|
||||
captured_headers = {}
|
||||
|
||||
async def fake_post(url, json, headers):
|
||||
captured_headers.update(headers)
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = _SAMPLE_RESPONSE
|
||||
return mock_response
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = fake_post
|
||||
|
||||
with patch("src.components.clients.siebel_client.settings") as mock_settings:
|
||||
mock_settings.SIEBEL_API_HOST = "https://fake-siebel.example.com"
|
||||
mock_settings.SIEBEL_API_TIMEOUT = 30
|
||||
mock_settings.VERIFY_SSL = False
|
||||
mock_settings.SIEBEL_API_USERNAME = ""
|
||||
mock_settings.SIEBEL_API_PASSWORD = ""
|
||||
mock_settings.SIEBEL_API_CLIENT_ID = ""
|
||||
mock_settings.LOG_LEVEL = "INFO"
|
||||
mock_settings.LOG_FORMAT = "text"
|
||||
client = SiebelClient()
|
||||
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
assert "Authorization" not in captured_headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_service_request — error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_http_status_error(status_code: int, body: str) -> httpx.HTTPStatusError:
|
||||
"""Build a realistic httpx.HTTPStatusError for testing."""
|
||||
response = MagicMock(spec=httpx.Response)
|
||||
response.status_code = status_code
|
||||
response.text = body
|
||||
request = MagicMock(spec=httpx.Request)
|
||||
return httpx.HTTPStatusError(message=body, request=request, response=response)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_raises_siebel_http_error_on_4xx():
|
||||
"""HTTPStatusError from httpx must be wrapped into SiebelHttpError."""
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = AsyncMock(
|
||||
side_effect=_make_http_status_error(400, "Bad Request")
|
||||
)
|
||||
|
||||
client = _make_client()
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
with pytest.raises(SiebelHttpError) as exc_info:
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "400" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_raises_siebel_http_error_on_5xx():
|
||||
"""5xx responses must also raise SiebelHttpError."""
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = AsyncMock(
|
||||
side_effect=_make_http_status_error(550, "Generic Error")
|
||||
)
|
||||
|
||||
client = _make_client()
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
with pytest.raises(SiebelHttpError) as exc_info:
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
assert exc_info.value.status_code == 550
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_raises_siebel_http_error_is_subclass_of_base():
|
||||
"""SiebelHttpError must be catchable via SiebelClientError."""
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = AsyncMock(
|
||||
side_effect=_make_http_status_error(500, "Server Error")
|
||||
)
|
||||
|
||||
client = _make_client()
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
with pytest.raises(SiebelClientError):
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_raises_siebel_connection_error():
|
||||
"""httpx.ConnectError must be wrapped into SiebelConnectionError."""
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = AsyncMock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
|
||||
client = _make_client()
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
with pytest.raises(SiebelConnectionError):
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_raises_siebel_timeout_error():
|
||||
"""httpx.TimeoutException must be wrapped into SiebelTimeoutError."""
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = AsyncMock(
|
||||
side_effect=httpx.TimeoutException("Request timed out")
|
||||
)
|
||||
|
||||
client = _make_client()
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
with pytest.raises(SiebelTimeoutError):
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_service_request_raises_siebel_client_error_on_unexpected():
|
||||
"""Unexpected exceptions must be wrapped into the base SiebelClientError."""
|
||||
mock_http_client = AsyncMock()
|
||||
mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
|
||||
mock_http_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_http_client.post = AsyncMock(
|
||||
side_effect=RuntimeError("Something totally unexpected")
|
||||
)
|
||||
|
||||
client = _make_client()
|
||||
with patch("src.components.clients.siebel_client.traced_async_client", return_value=mock_http_client):
|
||||
with pytest.raises(SiebelClientError):
|
||||
await client.open_service_request(payload=_SAMPLE_PAYLOAD)
|
||||
215
tests_original_develop/unit/test_siebel_sr_opening_node.py
Normal file
215
tests_original_develop/unit/test_siebel_sr_opening_node.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Unit tests for siebel_sr_opening_node."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from src.agent.nodes import siebel_sr_opening_node
|
||||
from src.components.clients.exceptions.siebel_exceptions import (
|
||||
SiebelClientError,
|
||||
SiebelHttpError,
|
||||
SiebelConnectionError,
|
||||
SiebelTimeoutError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BASE_CONTEXT = {
|
||||
"customer": {
|
||||
"cpf_cnpj": "06252533106",
|
||||
"claimed_msisdn": "62981152324",
|
||||
},
|
||||
"channel": "Anatel",
|
||||
"reason1": "Processo Interno",
|
||||
"reason2": "Turbina",
|
||||
"reason3": "Internalização",
|
||||
"notes": "Teste de abertura de SR",
|
||||
}
|
||||
|
||||
_SIEBEL_SUCCESS_RESPONSE = {"interactionProtocol": "2026000099999"}
|
||||
|
||||
|
||||
def _make_state(request_context: dict = None, omit_context: bool = False) -> dict:
|
||||
"""Build a minimal AgentState for testing."""
|
||||
metadata = {} if omit_context else {"request_context": request_context or _BASE_CONTEXT.copy()}
|
||||
return {
|
||||
"messages": [HumanMessage(content="test")],
|
||||
"current_step": "ticket_classified",
|
||||
"iteration_count": 1,
|
||||
"tool_results": None,
|
||||
"final_response": None,
|
||||
"session_id": "test-session",
|
||||
"metadata": metadata,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
def _make_mock_client(response: dict = None, side_effect=None):
|
||||
"""Return a mock SiebelClient with open_service_request pre-configured."""
|
||||
mock = MagicMock()
|
||||
if side_effect:
|
||||
mock.open_service_request = AsyncMock(side_effect=side_effect)
|
||||
else:
|
||||
mock.open_service_request = AsyncMock(return_value=response or _SIEBEL_SUCCESS_RESPONSE)
|
||||
return mock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_success_sets_step_to_opened():
|
||||
"""On success, current_step must be 'siebel_sr_opened'."""
|
||||
mock_client = _make_mock_client()
|
||||
with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client):
|
||||
state = _make_state()
|
||||
result = await siebel_sr_opening_node.process(state)
|
||||
|
||||
assert result["current_step"] == "siebel_sr_opened"
|
||||
assert result["error"] is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_success_stores_siebel_response_in_context():
|
||||
"""On success, the Siebel response must be written to request_context['siebel_sr_data']."""
|
||||
mock_client = _make_mock_client()
|
||||
with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client):
|
||||
state = _make_state()
|
||||
result = await siebel_sr_opening_node.process(state)
|
||||
|
||||
ctx = result["metadata"]["request_context"]
|
||||
assert "siebel_sr_data" in ctx
|
||||
assert ctx["siebel_sr_data"] == _SIEBEL_SUCCESS_RESPONSE
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_calls_client_with_correct_payload():
|
||||
"""open_service_request must be called with the expected payload fields."""
|
||||
mock_client = _make_mock_client()
|
||||
with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client):
|
||||
state = _make_state()
|
||||
await siebel_sr_opening_node.process(state)
|
||||
|
||||
call_kwargs = mock_client.open_service_request.call_args
|
||||
payload = call_kwargs.kwargs.get("payload") or call_kwargs.args[0]
|
||||
|
||||
assert payload["socialSecNo"] == _BASE_CONTEXT["customer"]["cpf_cnpj"]
|
||||
assert payload["assetId"] == _BASE_CONTEXT["customer"]["claimed_msisdn"]
|
||||
assert payload["serviceRequest"]["reason1"] == _BASE_CONTEXT["reason1"]
|
||||
assert payload["serviceRequest"]["reason2"] == _BASE_CONTEXT["reason2"]
|
||||
assert payload["serviceRequest"]["reason3"] == _BASE_CONTEXT["reason3"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_passes_notes_in_payload():
|
||||
"""Notes from context must be forwarded to the payload."""
|
||||
mock_client = _make_mock_client()
|
||||
with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client):
|
||||
state = _make_state()
|
||||
await siebel_sr_opening_node.process(state)
|
||||
|
||||
payload = mock_client.open_service_request.call_args.kwargs.get("payload") \
|
||||
or mock_client.open_service_request.call_args.args[0]
|
||||
assert payload["serviceRequest"]["notes"] == _BASE_CONTEXT["notes"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_missing_request_context_returns_error():
|
||||
"""When request_context is absent, node must return a ValidationError."""
|
||||
state = _make_state(omit_context=True)
|
||||
result = await siebel_sr_opening_node.process(state)
|
||||
|
||||
assert result["error"] is not None
|
||||
assert result["error"]["type"] == "ValidationError"
|
||||
assert "request_context" in result["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_missing_cpf_returns_error():
|
||||
"""When CPF is absent, node must return a ValidationError."""
|
||||
ctx = _BASE_CONTEXT.copy()
|
||||
ctx["customer"] = {"claimed_msisdn": "62981152324"} # no cpf_cnpj
|
||||
state = _make_state(request_context=ctx)
|
||||
result = await siebel_sr_opening_node.process(state)
|
||||
|
||||
assert result["error"] is not None
|
||||
assert result["error"]["type"] == "ValidationError"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_missing_msisdn_returns_error():
|
||||
"""When MSISDN is absent, node must return a ValidationError."""
|
||||
ctx = _BASE_CONTEXT.copy()
|
||||
ctx["customer"] = {"cpf_cnpj": "06252533106"} # no claimed_msisdn
|
||||
state = _make_state(request_context=ctx)
|
||||
result = await siebel_sr_opening_node.process(state)
|
||||
|
||||
assert result["error"] is not None
|
||||
assert result["error"]["type"] == "ValidationError"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Siebel client errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"exception, expected_error_type",
|
||||
[
|
||||
(SiebelHttpError(550, "Generic Error"), "SiebelHttpError"),
|
||||
(SiebelConnectionError("Connection refused"), "SiebelConnectionError"),
|
||||
(SiebelTimeoutError("Request timed out"), "SiebelTimeoutError"),
|
||||
(SiebelClientError("Unexpected error"), "SiebelClientError"),
|
||||
],
|
||||
)
|
||||
async def test_process_client_errors_set_error_in_state(exception, expected_error_type):
|
||||
"""All SiebelClientError subclasses must be caught and stored in state['error']."""
|
||||
mock_client = _make_mock_client(side_effect=exception)
|
||||
with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client):
|
||||
state = _make_state()
|
||||
result = await siebel_sr_opening_node.process(state)
|
||||
|
||||
assert result["error"] is not None
|
||||
assert result["error"]["type"] == expected_error_type
|
||||
assert result["error"]["step"] == "siebel_sr_opening"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_http_error_does_not_raise():
|
||||
"""Node must not propagate SiebelHttpError — it must catch and return error state."""
|
||||
mock_client = _make_mock_client(side_effect=SiebelHttpError(400, "Bad Request"))
|
||||
with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client):
|
||||
state = _make_state()
|
||||
result = await siebel_sr_opening_node.process(state) # must not raise
|
||||
|
||||
assert result["error"] is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_error_does_not_update_siebel_sr_data():
|
||||
"""On error, siebel_sr_data must not be set in context."""
|
||||
mock_client = _make_mock_client(side_effect=SiebelConnectionError("no route to host"))
|
||||
with patch("src.agent.nodes.siebel_sr_opening_node.SiebelClient", return_value=mock_client):
|
||||
state = _make_state()
|
||||
result = await siebel_sr_opening_node.process(state)
|
||||
|
||||
ctx = result["metadata"].get("request_context", {})
|
||||
assert "siebel_sr_data" not in ctx
|
||||
120
tests_original_develop/unit/test_validation.py
Normal file
120
tests_original_develop/unit/test_validation.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from src.agent.logic.validation import validate_required_fields
|
||||
from src.api.schemas.anatel_schemas import (
|
||||
DataSourceEvent, ReasonCode
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def valid_ticket_payload():
|
||||
return {
|
||||
"source_system": "test",
|
||||
"ingested_at": datetime.now(),
|
||||
"correlation_id": "123",
|
||||
"data_source_ticket": {
|
||||
"data_source_ticket_id": "TICKET-123",
|
||||
"status": "OPEN",
|
||||
"action_type": "TEST",
|
||||
"area_fechamento": "AREA",
|
||||
"site_fechamento": "SITE",
|
||||
"grupo_skill_responsavel": "SKILL",
|
||||
"canal_origem": "WEB"
|
||||
},
|
||||
"anatel": {
|
||||
"anatel_complaint_id": "COMP-1",
|
||||
"protocol": "PROT-123",
|
||||
"opened_at": datetime.now(),
|
||||
"due_at": datetime.now(),
|
||||
"title": "Title",
|
||||
"motive": "Motive",
|
||||
"submotive": "Submotive",
|
||||
"description": "Desc",
|
||||
"service": "Service",
|
||||
"first_service": "FS",
|
||||
"modality": "Modality"
|
||||
},
|
||||
"crm": {
|
||||
"siebel_sr_number": "SR-1",
|
||||
"customer_segment": "SEG",
|
||||
"odc_customer": False,
|
||||
"contumaz_customer": False
|
||||
},
|
||||
"customer": {
|
||||
"customer_name": "Test Name",
|
||||
"cpf_cnpj": "12345678909",
|
||||
"phones": ["11999999999"],
|
||||
"address": {
|
||||
"cep": "00000000",
|
||||
"street": "Rua",
|
||||
"number": "1",
|
||||
"neighborhood": "Bairro",
|
||||
"city": "City",
|
||||
"state": "SP"
|
||||
}
|
||||
},
|
||||
"routing_inputs": {
|
||||
"acionamentos_count": 0,
|
||||
"routing_group_skill": "G",
|
||||
"routing_modalidade": "M",
|
||||
"routing_priority": "P",
|
||||
"speech_expected": True
|
||||
},
|
||||
"attachments": {"consumer_attachments": [], "data_source_attachments": []},
|
||||
"subscriber": {
|
||||
"cpf_cnpj": "12345678909",
|
||||
"customer_name": "Subscriber Name",
|
||||
"contact_phone": "11999999999",
|
||||
"contact_name": "Contact Name"
|
||||
},
|
||||
"history_ids": [],
|
||||
"raw_print_fields": {}
|
||||
}
|
||||
|
||||
def test_validate_required_fields_success(valid_ticket_payload):
|
||||
ticket = DataSourceEvent(**valid_ticket_payload)
|
||||
result = validate_required_fields(ticket)
|
||||
assert result.is_valid is True
|
||||
assert result.error_response is None
|
||||
|
||||
def test_validate_missing_cpf(valid_ticket_payload):
|
||||
valid_ticket_payload["customer"]["cpf_cnpj"] = ""
|
||||
valid_ticket_payload["subscriber"]["cpf_cnpj"] = ""
|
||||
ticket = DataSourceEvent(**valid_ticket_payload)
|
||||
result = validate_required_fields(ticket)
|
||||
|
||||
assert result.is_valid is False
|
||||
assert result.error_response["status"] == 400
|
||||
messages = result.error_response["detail"]["messages"]
|
||||
assert any(m["code"] == ReasonCode.MISSING_CPF.value for m in messages)
|
||||
|
||||
def test_validate_missing_multiple_fields(valid_ticket_payload):
|
||||
valid_ticket_payload["customer"]["cpf_cnpj"] = ""
|
||||
valid_ticket_payload["subscriber"]["cpf_cnpj"] = ""
|
||||
valid_ticket_payload["anatel"]["title"] = ""
|
||||
valid_ticket_payload["data_source_ticket"]["data_source_ticket_id"] = ""
|
||||
|
||||
ticket = DataSourceEvent(**valid_ticket_payload)
|
||||
result = validate_required_fields(ticket)
|
||||
|
||||
assert result.is_valid is False
|
||||
messages = result.error_response["detail"]["messages"]
|
||||
codes = [m["code"] for m in messages]
|
||||
|
||||
assert ReasonCode.MISSING_CPF.value in codes
|
||||
assert ReasonCode.MISSING_TITLE.value in codes
|
||||
assert ReasonCode.MISSING_IDS.value in codes
|
||||
|
||||
|
||||
def test_validate_missing_anatel_motive(valid_ticket_payload):
|
||||
"""Valida rejeição por falta de motivo da Anatel."""
|
||||
valid_ticket_payload["anatel"]["motive"] = " "
|
||||
ticket = DataSourceEvent(**valid_ticket_payload)
|
||||
result = validate_required_fields(ticket)
|
||||
assert result.error_response["detail"]["messages"][0]["code"] == ReasonCode.MISSING_MOTIVE.value
|
||||
|
||||
# def test_validate_missing_opened_at(valid_ticket_payload):
|
||||
# """Valida rejeição por falta de data de abertura."""
|
||||
# valid_ticket_payload["anatel"]["opened_at"] = None
|
||||
# ticket = DataSourceEvent(**valid_ticket_payload)
|
||||
# result = validate_required_fields(ticket)
|
||||
# assert result.error_response["detail"]["messages"][0]["code"] == ReasonCode.MISSING_OPENED_AT.value
|
||||
53
tests_original_develop/verify_prompts.py
Normal file
53
tests_original_develop/verify_prompts.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.core.prompt_manager import get_prompt, _prompt_cache
|
||||
from src.core.config import settings
|
||||
import time
|
||||
|
||||
def test_prompt_manager():
|
||||
print("Starting Prompt Manager Verification...")
|
||||
|
||||
local_p = "LOCAL PROMPT"
|
||||
prompt_name = "test_prompt"
|
||||
|
||||
# 1. Test Local Fallback (Langfuse Disabled)
|
||||
settings.USE_LANGFUSE_PROMPTS = False
|
||||
p = get_prompt(prompt_name, local_p)
|
||||
assert p == local_p
|
||||
print("PASS: Local fallback when USE_LANGFUSE_PROMPTS=False")
|
||||
|
||||
# 2. Test Fetching (Langfuse Enabled) - This will likely fail or return fallback if not configured
|
||||
settings.USE_LANGFUSE_PROMPTS = True
|
||||
print("\nTesting with USE_LANGFUSE_PROMPTS=True...")
|
||||
# We expect this to either work (if keys are valid and prompt exists)
|
||||
# or fall back gracefully (if keys are invalid or prompt doesn't exist)
|
||||
p = get_prompt(prompt_name, local_p)
|
||||
print(f"Result for '{prompt_name}': {p[:50]}...")
|
||||
|
||||
# 3. Test Cache
|
||||
print("\nTesting TTL Cache...")
|
||||
# Manually inject into cache
|
||||
_prompt_cache[prompt_name] = ("CACHED CONTENT", time.time())
|
||||
|
||||
# TTL is 600 by default. This should return cached content.
|
||||
p = get_prompt(prompt_name, local_p)
|
||||
assert p == "CACHED CONTENT"
|
||||
print("PASS: Cache hit before TTL expiry")
|
||||
|
||||
# Test TTL Expiry
|
||||
print("\nTesting Cache Expiry...")
|
||||
_prompt_cache[prompt_name] = ("EXPIRED CONTENT", time.time() - 1000)
|
||||
# Should attempt to fetch (and likely fall back to local if test_prompt doesn't exist in Langfuse)
|
||||
p = get_prompt(prompt_name, local_p)
|
||||
# If fetch fails, it returns local_p. If it hits Langfuse, it returns that.
|
||||
# The important part is that it didn't return "EXPIRED CONTENT".
|
||||
assert p != "EXPIRED CONTENT"
|
||||
print(f"PASS: Cache expiry handled (Returned: {p})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_prompt_manager()
|
||||
print("\nAll logical checks passed!")
|
||||
except Exception as e:
|
||||
print(f"\nVerification FAILED: {e}")
|
||||
Reference in New Issue
Block a user