first commit

This commit is contained in:
2026-06-13 08:23:21 -03:00
commit 89c23fb0ed
439 changed files with 32801 additions and 0 deletions

BIN
tests_original_develop/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
"""Test suite for Python Agent Boilerplate."""

View 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

View File

@@ -0,0 +1 @@
"""Test fixtures for the agent boilerplate."""

View 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()

View File

@@ -0,0 +1 @@
"""Integration tests."""

View File

@@ -0,0 +1 @@
"""Property-based tests using Hypothesis."""

View File

@@ -0,0 +1 @@
"""Unit tests."""

View 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)

View 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")

View 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

View 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"

View 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"

View 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

View 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"

View 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

View 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)

View 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

View 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

View 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"]

View 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)

View 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

View 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

View 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}")