Projeto do Agent Contas ORACLE
This commit is contained in:
11
tests/migration/conftest.py
Normal file
11
tests/migration/conftest.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRAMEWORK_SRC = ROOT / "agent_framework_oci" / "libs" / "agent_framework" / "src"
|
||||
for path in (ROOT, FRAMEWORK_SRC):
|
||||
value = str(path)
|
||||
if value not in sys.path:
|
||||
sys.path.insert(0, value)
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"id": "contestacao_tool_tipo_atendimento_cancelamento_vas_avulso",
|
||||
"workflow": "contestacao_tool",
|
||||
"version": 2,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"social_sec_no": "12345678901",
|
||||
"tipo_atendimento": "cancelamento_vas_avulso",
|
||||
"servico": "TIM Segurança",
|
||||
"valor": "9.99"
|
||||
},
|
||||
"actions": {
|
||||
"registrar_protocolo": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocolo_id": "PROTO-CONTESTACAO"
|
||||
}
|
||||
},
|
||||
"consultar_perfil_fatura": {
|
||||
"output": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"check_invoice_status": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"invoice_status": "1"
|
||||
}
|
||||
},
|
||||
"abrir_contestacao_cliente": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"manual_conta_certa_indicator": false
|
||||
}
|
||||
},
|
||||
"consultar_contrato_corte": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"apos_data_corte": false,
|
||||
"contestation_success": true,
|
||||
"contestation_registered": true,
|
||||
"dependent_invoice_item": false
|
||||
}
|
||||
},
|
||||
"atualizar_status_sr": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "Fechado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "atualizar_status_sr",
|
||||
"trace_nodes": [
|
||||
"registrar_protocolo",
|
||||
"consultar_perfil_fatura",
|
||||
"check_invoice_status",
|
||||
"abrir_contestacao_cliente",
|
||||
"consultar_contrato_corte",
|
||||
"atualizar_status_sr"
|
||||
],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "abrir_contestacao_cliente",
|
||||
"params_subset": {
|
||||
"tipo_atendimento": "cancelamento_vas_avulso"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"id": "contestacao_tool_tipo_atendimento_contestacao",
|
||||
"workflow": "contestacao_tool",
|
||||
"version": 2,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"social_sec_no": "12345678901",
|
||||
"tipo_atendimento": "contestacao",
|
||||
"servico": "Serviço contestado",
|
||||
"valor": "19.90"
|
||||
},
|
||||
"actions": {
|
||||
"registrar_protocolo": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocolo_id": "PROTO-CONTESTACAO"
|
||||
}
|
||||
},
|
||||
"consultar_perfil_fatura": {
|
||||
"output": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"check_invoice_status": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"invoice_status": "1"
|
||||
}
|
||||
},
|
||||
"abrir_contestacao_cliente": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"manual_conta_certa_indicator": false
|
||||
}
|
||||
},
|
||||
"consultar_contrato_corte": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"apos_data_corte": false,
|
||||
"contestation_success": true,
|
||||
"contestation_registered": true,
|
||||
"dependent_invoice_item": false
|
||||
}
|
||||
},
|
||||
"atualizar_status_sr": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "Fechado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "atualizar_status_sr",
|
||||
"trace_nodes": [
|
||||
"registrar_protocolo",
|
||||
"consultar_perfil_fatura",
|
||||
"check_invoice_status",
|
||||
"abrir_contestacao_cliente",
|
||||
"consultar_contrato_corte",
|
||||
"atualizar_status_sr"
|
||||
],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "abrir_contestacao_cliente",
|
||||
"params_subset": {
|
||||
"tipo_atendimento": "contestacao"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"id": "contestacao_tool_tipo_atendimento_pro_rata",
|
||||
"workflow": "contestacao_tool",
|
||||
"version": 2,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"social_sec_no": "12345678901",
|
||||
"tipo_atendimento": "pro_rata",
|
||||
"servico": "Plano Controle",
|
||||
"valor": "29.90"
|
||||
},
|
||||
"actions": {
|
||||
"registrar_protocolo": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocolo_id": "PROTO-CONTESTACAO"
|
||||
}
|
||||
},
|
||||
"consultar_perfil_fatura": {
|
||||
"output": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"check_invoice_status": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"invoice_status": "1"
|
||||
}
|
||||
},
|
||||
"abrir_contestacao_cliente": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"manual_conta_certa_indicator": false
|
||||
}
|
||||
},
|
||||
"consultar_contrato_corte": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"apos_data_corte": false,
|
||||
"contestation_success": true,
|
||||
"contestation_registered": true,
|
||||
"dependent_invoice_item": false
|
||||
}
|
||||
},
|
||||
"atualizar_status_sr": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "Fechado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "atualizar_status_sr",
|
||||
"trace_nodes": [
|
||||
"registrar_protocolo",
|
||||
"consultar_perfil_fatura",
|
||||
"check_invoice_status",
|
||||
"abrir_contestacao_cliente",
|
||||
"consultar_contrato_corte",
|
||||
"atualizar_status_sr"
|
||||
],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "abrir_contestacao_cliente",
|
||||
"params_subset": {
|
||||
"tipo_atendimento": "pro_rata"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"id": "contestacao_tool_tipo_atendimento_valor_divergente",
|
||||
"workflow": "contestacao_tool",
|
||||
"version": 2,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"social_sec_no": "12345678901",
|
||||
"tipo_atendimento": "valor_divergente",
|
||||
"servico": "Fatura",
|
||||
"valor": "49.90"
|
||||
},
|
||||
"actions": {
|
||||
"registrar_protocolo": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocolo_id": "PROTO-CONTESTACAO"
|
||||
}
|
||||
},
|
||||
"consultar_perfil_fatura": {
|
||||
"output": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"check_invoice_status": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"invoice_status": "1"
|
||||
}
|
||||
},
|
||||
"abrir_contestacao_cliente": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"manual_conta_certa_indicator": false
|
||||
}
|
||||
},
|
||||
"consultar_contrato_corte": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"apos_data_corte": false,
|
||||
"contestation_success": true,
|
||||
"contestation_registered": true,
|
||||
"dependent_invoice_item": false
|
||||
}
|
||||
},
|
||||
"atualizar_status_sr": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "Fechado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "atualizar_status_sr",
|
||||
"trace_nodes": [
|
||||
"registrar_protocolo",
|
||||
"consultar_perfil_fatura",
|
||||
"check_invoice_status",
|
||||
"abrir_contestacao_cliente",
|
||||
"consultar_contrato_corte",
|
||||
"atualizar_status_sr"
|
||||
],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "abrir_contestacao_cliente",
|
||||
"params_subset": {
|
||||
"tipo_atendimento": "valor_divergente"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "finalizar_atendimento_erro_no_match",
|
||||
"workflow": "finalizar_atendimento",
|
||||
"version": 1,
|
||||
"input": {
|
||||
"status": "erro_no_match",
|
||||
"summary": "Cliente insistiu em assunto fora de fatura e fora das jornadas atendíveis."
|
||||
},
|
||||
"actions": {
|
||||
"finalizar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "erro_no_match",
|
||||
"summary": "Cliente insistiu em assunto fora de fatura e fora das jornadas atendíveis."
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "finalizar",
|
||||
"data_subset": {
|
||||
"status": "erro_no_match"
|
||||
},
|
||||
"trace_nodes": ["finalizar"],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "finalizar",
|
||||
"params_subset": {
|
||||
"status": "erro_no_match"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "finalizar_atendimento_nao_resolvido",
|
||||
"workflow": "finalizar_atendimento",
|
||||
"version": 1,
|
||||
"input": {
|
||||
"status": "nao_resolvido",
|
||||
"summary": "Cliente pediu atendimento humano."
|
||||
},
|
||||
"actions": {
|
||||
"finalizar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "nao_resolvido",
|
||||
"summary": "Cliente pediu atendimento humano."
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "finalizar",
|
||||
"data_subset": {
|
||||
"status": "nao_resolvido"
|
||||
},
|
||||
"trace_nodes": ["finalizar"],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "finalizar",
|
||||
"params_subset": {
|
||||
"status": "nao_resolvido"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "finalizar_atendimento_outros_assuntos",
|
||||
"workflow": "finalizar_atendimento",
|
||||
"version": 1,
|
||||
"input": {
|
||||
"status": "outros_assuntos",
|
||||
"summary": "Cliente pediu uma jornada legítima fora do escopo de contas."
|
||||
},
|
||||
"actions": {
|
||||
"finalizar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "outros_assuntos",
|
||||
"summary": "Cliente pediu uma jornada legítima fora do escopo de contas."
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "finalizar",
|
||||
"data_subset": {
|
||||
"status": "outros_assuntos"
|
||||
},
|
||||
"trace_nodes": ["finalizar"],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "finalizar",
|
||||
"params_subset": {
|
||||
"status": "outros_assuntos"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "finalizar_atendimento_resolvido",
|
||||
"workflow": "finalizar_atendimento",
|
||||
"version": 1,
|
||||
"input": {
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente entendeu a explicação."
|
||||
},
|
||||
"actions": {
|
||||
"finalizar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente entendeu a explicação."
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "finalizar",
|
||||
"data_subset": {
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente entendeu a explicação."
|
||||
},
|
||||
"trace_nodes": ["finalizar"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "finalizar_atendimento_resolvido_outros_assuntos",
|
||||
"workflow": "finalizar_atendimento",
|
||||
"version": 1,
|
||||
"input": {
|
||||
"status": "resolvido_outros_assuntos",
|
||||
"summary": "Dúvida de contas resolvida, mas cliente quer tratar outro assunto."
|
||||
},
|
||||
"actions": {
|
||||
"finalizar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"status": "resolvido_outros_assuntos",
|
||||
"summary": "Dúvida de contas resolvida, mas cliente quer tratar outro assunto."
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "finalizar",
|
||||
"data_subset": {
|
||||
"status": "resolvido_outros_assuntos"
|
||||
},
|
||||
"trace_nodes": ["finalizar"],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "finalizar",
|
||||
"params_subset": {
|
||||
"status": "resolvido_outros_assuntos"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"id": "invoice_explanation_nao_registra_nao",
|
||||
"workflow": "invoice_explanation",
|
||||
"version": 2,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"invoice_explanation_base": "A fatura aumentou por causa de juros."
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"explicacao_base": "A fatura aumentou por causa de juros.",
|
||||
"tentativa": 0
|
||||
}
|
||||
},
|
||||
"formatar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": true,
|
||||
"mensagem": "A fatura aumentou por causa de juros. Sanei sua dúvida?"
|
||||
}
|
||||
},
|
||||
"registrar_nao": {
|
||||
"output": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"checar_vas_variacao": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"tem_vas_variado": true,
|
||||
"qtd_vas_variados": 2,
|
||||
"decisao_vas_variacao": "tem_vas_variado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect_start": {
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO"]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "NAO"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "checar_vas_variacao",
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"formatar",
|
||||
"decisao",
|
||||
"registrar_nao",
|
||||
"checar_vas_variacao"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"id": "invoice_explanation_nao_sem_vas_finaliza",
|
||||
"workflow": "invoice_explanation",
|
||||
"version": 2,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"invoice_explanation_base": "A fatura aumentou por causa de juros."
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"explicacao_base": "A fatura aumentou por causa de juros.",
|
||||
"tentativa": 0
|
||||
}
|
||||
},
|
||||
"formatar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": true,
|
||||
"mensagem": "A fatura aumentou por causa de juros. Sanei sua dúvida?"
|
||||
}
|
||||
},
|
||||
"registrar_nao": {
|
||||
"output": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"checar_vas_variacao": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"tem_vas_variado": false,
|
||||
"qtd_vas_variados": 0,
|
||||
"decisao_vas_variacao": "finaliza_nao_resolvido"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect_start": {
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO"]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "NAO"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "finalizar_nao_resolvido",
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"formatar",
|
||||
"decisao",
|
||||
"registrar_nao",
|
||||
"checar_vas_variacao",
|
||||
"finalizar_nao_resolvido"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"id": "invoice_explanation_sim_abre_protocolo_aceite",
|
||||
"workflow": "invoice_explanation",
|
||||
"version": 2,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"invoice_explanation_base": "A fatura aumentou por causa de juros."
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"explicacao_base": "A fatura aumentou por causa de juros.",
|
||||
"tentativa": 0
|
||||
}
|
||||
},
|
||||
"formatar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": true,
|
||||
"mensagem": "A fatura aumentou por causa de juros. Sanei sua dúvida?"
|
||||
}
|
||||
},
|
||||
"registrar_sim": {
|
||||
"output": {
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"registrar_protocolo_aceite": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocol_closed": true,
|
||||
"protocolo_id": "PROTO-ACEITE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect_start": {
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO"]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "SIM"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "registrar_protocolo_aceite",
|
||||
"data_subset": {
|
||||
"protocolo_id": "PROTO-ACEITE"
|
||||
},
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"formatar",
|
||||
"decisao",
|
||||
"registrar_sim",
|
||||
"registrar_protocolo_aceite"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"id": "pro_rata_aceitou_esclarecimento_registra_mensalidade",
|
||||
"workflow": "pro_rata",
|
||||
"version": 3,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"has_plano_controle": true,
|
||||
"planos": [
|
||||
{
|
||||
"nome": "TIM Controle"
|
||||
}
|
||||
]
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": true,
|
||||
"mensagem": "Expliquei a cobrança proporcional. Sanei sua dúvida?",
|
||||
"mensagem_pos_aceite": "Obrigado pela confirmação."
|
||||
}
|
||||
},
|
||||
"formatar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"mensagem": "Expliquei a cobrança proporcional. Sanei sua dúvida?"
|
||||
}
|
||||
},
|
||||
"registrar_aceitou": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocol_closed": true,
|
||||
"protocolo_id": "PROTO-PRO-RATA-ACEITE",
|
||||
"tripleta": {
|
||||
"reason1": "Informação",
|
||||
"reason2": "Conta",
|
||||
"reason3": "Mensalidade"
|
||||
},
|
||||
"ic": "VEB.003"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect_start": {
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO", "OUTRO"]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "SIM"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "registrar_aceitou",
|
||||
"data_subset": {
|
||||
"protocolo_id": "PROTO-PRO-RATA-ACEITE",
|
||||
"ic": "VEB.003"
|
||||
},
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"formatar",
|
||||
"decisao_esclarecimento",
|
||||
"registrar_aceitou",
|
||||
"registrar_aceitou"
|
||||
],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "registrar_aceitou",
|
||||
"params_subset": {
|
||||
"caminho": "aceitou",
|
||||
"ic": "VEB.003"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"id": "pro_rata_nao_aceitou_devolve_orquestrador",
|
||||
"workflow": "pro_rata",
|
||||
"version": 3,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"has_plano_controle": true,
|
||||
"planos": [
|
||||
{
|
||||
"nome": "TIM Controle"
|
||||
}
|
||||
]
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": true,
|
||||
"mensagem": "Expliquei a cobrança proporcional. Sanei sua dúvida?"
|
||||
}
|
||||
},
|
||||
"formatar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"mensagem": "Expliquei a cobrança proporcional. Sanei sua dúvida?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect_start": {
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "formatar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO", "OUTRO"]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "NAO"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "devolver_orquestrador",
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"formatar",
|
||||
"decisao_esclarecimento",
|
||||
"devolver_orquestrador"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"id": "pro_rata_nao_controle_registra_mensalidade",
|
||||
"workflow": "pro_rata",
|
||||
"version": 3,
|
||||
"input": {
|
||||
"msisdn": "11999999999",
|
||||
"has_plano_controle": false,
|
||||
"planos": []
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": false,
|
||||
"mensagem": "Não identifiquei Plano Controle elegível para pro-rata."
|
||||
}
|
||||
},
|
||||
"formatar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"mensagem": "Não identifiquei Plano Controle elegível para pro-rata."
|
||||
}
|
||||
},
|
||||
"registrar_nao_controle": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocol_closed": true,
|
||||
"protocolo_id": "PROTO-PRO-RATA-NAO-CONTROLE",
|
||||
"tripleta": {
|
||||
"reason1": "Informação",
|
||||
"reason2": "Conta",
|
||||
"reason3": "Mensalidade"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "registrar_nao_controle",
|
||||
"data_subset": {
|
||||
"protocolo_id": "PROTO-PRO-RATA-NAO-CONTROLE"
|
||||
},
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"formatar",
|
||||
"registrar_nao_controle"
|
||||
],
|
||||
"calls": [
|
||||
{
|
||||
"node_id": "registrar_nao_controle",
|
||||
"params_subset": {
|
||||
"caminho": "nao_controle"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": "vas_bundle_puro_nao_reforca_e_registra_bundle",
|
||||
"workflow": "vas_estrategico",
|
||||
"version": 3,
|
||||
"input": {
|
||||
"items": [
|
||||
{
|
||||
"type": "bundle",
|
||||
"msisdn": "11999999999",
|
||||
"name": "TIM Segurança"
|
||||
}
|
||||
],
|
||||
"linhas": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"bundle_names": ["TIM Segurança"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": false,
|
||||
"mensagem": "TIM Segurança está incluso no plano.",
|
||||
"mensagem_bundle_fechamento": "TIM Segurança está incluso no plano.",
|
||||
"linhas": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"bundle_names": ["TIM Segurança"]
|
||||
}
|
||||
],
|
||||
"has_estrategico_items": false
|
||||
}
|
||||
},
|
||||
"resposta_bundle": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"mensagem": "TIM Segurança está incluso no plano."
|
||||
}
|
||||
},
|
||||
"registrar_bundle": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocol_closed": false,
|
||||
"protocol_deferred_to_finalization": true,
|
||||
"informational_vas_types": ["bundle"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "registrar_bundle",
|
||||
"data_subset": {
|
||||
"protocol_deferred_to_finalization": true
|
||||
},
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"resposta_bundle",
|
||||
"registrar_bundle"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"id": "vas_estrategico_nao_com_estrategico_explica_cancelamento",
|
||||
"workflow": "vas_estrategico",
|
||||
"version": 3,
|
||||
"input": {
|
||||
"items": [
|
||||
{
|
||||
"type": "estrategico",
|
||||
"msisdn": "11999999999",
|
||||
"name": "YouTube Premium Mensal"
|
||||
}
|
||||
],
|
||||
"linhas": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"estrategico_names": ["YouTube Premium Mensal"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": true,
|
||||
"mensagem": "Expliquei o VAS estratégico. Sanei sua dúvida?",
|
||||
"linhas": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"estrategico_names": ["YouTube Premium Mensal"]
|
||||
}
|
||||
],
|
||||
"has_estrategico_items": true
|
||||
}
|
||||
},
|
||||
"explicar_cancelamento": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"mensagem": "Para cancelar, use os canais de autoatendimento."
|
||||
}
|
||||
},
|
||||
"registrar_nao": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocol_closed": true,
|
||||
"protocolos_por_linha": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"protocolo_id": "PROTO-VAS"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect_start": {
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "preparar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO", "OUTRO"]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "NAO"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "registrar_nao",
|
||||
"data_subset": {
|
||||
"protocol_closed": true
|
||||
},
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"preparar",
|
||||
"decisao",
|
||||
"explicar_cancelamento",
|
||||
"registrar_nao"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"id": "vas_estrategico_sim_finaliza_sem_protocolo_imediato",
|
||||
"workflow": "vas_estrategico",
|
||||
"version": 3,
|
||||
"input": {
|
||||
"items": [
|
||||
{
|
||||
"type": "estrategico",
|
||||
"msisdn": "11999999999",
|
||||
"name": "YouTube Premium Mensal"
|
||||
}
|
||||
],
|
||||
"linhas": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"estrategico_names": ["YouTube Premium Mensal"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"actions": {
|
||||
"preparar": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"await_user_input": true,
|
||||
"mensagem": "Expliquei o VAS estratégico. Sanei sua dúvida?",
|
||||
"linhas": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"estrategico_names": ["YouTube Premium Mensal"]
|
||||
}
|
||||
],
|
||||
"has_estrategico_items": true,
|
||||
"mensagem_pos_aceite": "Obrigado pela confirmação."
|
||||
}
|
||||
},
|
||||
"resposta_sim": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"mensagem": "Obrigado pela confirmação."
|
||||
}
|
||||
},
|
||||
"registrar_sim": {
|
||||
"output": {
|
||||
"success": true,
|
||||
"protocol_closed": false,
|
||||
"protocol_deferred_to_finalization": true,
|
||||
"informational_vas_types": ["estrategico"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect_start": {
|
||||
"status": "WAITING_INPUT",
|
||||
"paused_at": "preparar",
|
||||
"expected_input_key": "resposta_usuario",
|
||||
"allowed_values": ["SIM", "NAO", "OUTRO"]
|
||||
},
|
||||
"resume": {
|
||||
"resposta_usuario": "SIM"
|
||||
},
|
||||
"expect": {
|
||||
"status": "COMPLETED",
|
||||
"last_node": "registrar_sim",
|
||||
"data_subset": {
|
||||
"protocol_deferred_to_finalization": true
|
||||
},
|
||||
"trace_nodes": [
|
||||
"preparar",
|
||||
"preparar",
|
||||
"decisao",
|
||||
"resposta_sim",
|
||||
"registrar_sim"
|
||||
]
|
||||
}
|
||||
}
|
||||
101
tests/migration/original_test_ausencia_oferta_proativa.py
Normal file
101
tests/migration/original_test_ausencia_oferta_proativa.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("USE_MOCK_LLM", "true")
|
||||
|
||||
from agent_framework.guardrails.calibrated import ausencia_oferta_proativa
|
||||
from agent_framework.guardrails.calibrated._compat import RailResult
|
||||
from agent_framework.guardrails.calibrated.prompts.ausencia_oferta_proativa import (
|
||||
build_aoferta_prompt,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Contrato de saída (2026-08-05): {allowed, reason}, reason vazio se allowed #
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_prompt_aoferta_nao_pede_label():
|
||||
# O `label` nunca existiu no prompt real (só no mock); o contrato é
|
||||
# {allowed, reason}, como o FRASEOLOGIA.
|
||||
prompt = build_aoferta_prompt("Gostaria de cancelar esse serviço?")
|
||||
assert '"label"' not in prompt
|
||||
assert '"allowed": true ou false' in prompt
|
||||
|
||||
|
||||
def test_prompt_aoferta_exige_reason_vazio_quando_allowed():
|
||||
prompt = build_aoferta_prompt("Gostaria de cancelar esse serviço?")
|
||||
assert "se allowed=true: string vazia" in prompt
|
||||
assert "se allowed=true: explicacao curta" not in prompt
|
||||
|
||||
|
||||
def test_prompt_aoferta_injeta_texto_e_contexto():
|
||||
prompt = build_aoferta_prompt("posso cancelar?", "\nHistorico:\n[user] oi\n")
|
||||
assert "posso cancelar?" in prompt
|
||||
assert "[user] oi" in prompt
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_mock(monkeypatch):
|
||||
monkeypatch.setenv("USE_MOCK_LLM", "true")
|
||||
|
||||
|
||||
def test_resposta_neutra_eh_permitida():
|
||||
r = ausencia_oferta_proativa("Posso te ajudar com mais alguma coisa?")
|
||||
|
||||
assert isinstance(r, RailResult)
|
||||
assert r.allowed is True
|
||||
assert r.code == "AOFERTA"
|
||||
assert r.mechanism == "llm_supervisor"
|
||||
assert r.data is not None
|
||||
assert r.data.get("label") == "OFERTA_OK"
|
||||
|
||||
|
||||
def test_oferta_proativa_indevida_bloqueia():
|
||||
r = ausencia_oferta_proativa(
|
||||
"Ja que esta cancelando um, quer aproveitar e cancelar os outros?",
|
||||
)
|
||||
|
||||
assert r.allowed is False
|
||||
assert r.code == "AOFERTA"
|
||||
assert r.mechanism == "llm_supervisor"
|
||||
assert r.data.get("label") == "OFERTA_PROATIVA_INDEVIDA"
|
||||
|
||||
|
||||
def test_padrao_aproveita_e_bloqueia():
|
||||
r = ausencia_oferta_proativa("Quer aproveitar e cancelar os outros?")
|
||||
|
||||
assert r.allowed is False
|
||||
assert r.code == "AOFERTA"
|
||||
|
||||
|
||||
def test_confirmacao_cancelamento_apos_nao_reconhecimento_eh_permitida():
|
||||
# Cliente nao-reconheceu os servicos em foco ("Nao contratei esses
|
||||
# servicos") e o agente confirma o cancelamento dos MESMOS itens. Per
|
||||
# classificador v9 + orquestrador, isso e action_request -> a confirmacao
|
||||
# deve ser OFERTA_OK, nao oferta proativa.
|
||||
#
|
||||
# NOTA: em USE_MOCK_LLM=true o mock decide AOFERTA por string-match no texto
|
||||
# do agente (sem gatilho de upsell -> OFERTA_OK), ignorando o contexto. Este
|
||||
# teste documenta a intencao; a validacao real do prompt e via LLM/cenarios
|
||||
# (automated_conversation_test cy0004/cy0005/cy0008).
|
||||
r = ausencia_oferta_proativa(
|
||||
"Podemos seguir com o cancelamento dos serviços VOD + Canais Abertos, "
|
||||
"no valor de R$ 19,90, e Aluguel de Filme 1, no valor de R$ 19,90, "
|
||||
"e providenciar a retirada dos valores da fatura, você confirma?",
|
||||
)
|
||||
|
||||
assert r.allowed is True
|
||||
assert r.code == "AOFERTA"
|
||||
assert r.data.get("label") == "OFERTA_OK"
|
||||
|
||||
|
||||
def test_upsell_continua_bloqueando_apos_mudanca():
|
||||
# Contraprova: a mudanca para reconhecer nao-reconhecimento NAO pode liberar
|
||||
# marcadores de upsell explicitos.
|
||||
r = ausencia_oferta_proativa(
|
||||
"Ja que esta cancelando esse, que tal tambem cancelar o outro servico?",
|
||||
)
|
||||
|
||||
assert r.allowed is False
|
||||
assert r.code == "AOFERTA"
|
||||
assert r.data.get("label") == "OFERTA_PROATIVA_INDEVIDA"
|
||||
61
tests/migration/original_test_bill_processor.py
Normal file
61
tests/migration/original_test_bill_processor.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import pandas as pd
|
||||
|
||||
from app.domain.contas.parsers.bill_processor import PDFBillingProcessor
|
||||
|
||||
|
||||
def test_danfe_payload_preserva_ordem_do_parser_nos_itens_do_plano() -> None:
|
||||
processor = PDFBillingProcessor(include_danfe=True)
|
||||
danfe_df = pd.DataFrame(
|
||||
[
|
||||
{"desc": "TIM CTRL Redes Sociais 8 0", "value": 35.96},
|
||||
{"desc": "Serviços de Valor Adicionado Conteúdo", "value": 99.99},
|
||||
{"desc": "Aya Books Premium", "value": 19.44},
|
||||
]
|
||||
)
|
||||
raw = {
|
||||
"DANFE-COM": danfe_df,
|
||||
"Plano": pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"desc": "TIM CTRL Redes Sociais 8 0 (096/POS/SMP)",
|
||||
"value": 35.96,
|
||||
}
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
payload = processor._build_danfe_payload(danfe_df, raw)
|
||||
|
||||
item_names = [
|
||||
item["desc"]
|
||||
for item in payload["Planos"]["TIM CTRL Redes Sociais 8 0"]
|
||||
]
|
||||
assert item_names == [
|
||||
"TIM CTRL Redes Sociais 8 0",
|
||||
"Serviços de Valor Adicionado Conteúdo",
|
||||
"Aya Books Premium",
|
||||
]
|
||||
|
||||
|
||||
def test_max_mensal_em_itens_eventuais_e_estrategico() -> None:
|
||||
"""Max Mensal (streaming) em "Itens Eventuais" recebe estrategico=True e
|
||||
classe=estrategico; o avulso comum mantém classe=avulso."""
|
||||
processor = PDFBillingProcessor()
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
{"desc": "Serviço Avulso X", "value": 9.90},
|
||||
{"desc": "Max Mensal", "value": 34.90},
|
||||
]
|
||||
)
|
||||
|
||||
regular, strategic = processor._split_strategic_records(df, "Itens Eventuais")
|
||||
|
||||
assert [r["desc"] for r in strategic] == ["Max Mensal"]
|
||||
assert strategic[0]["estrategico"] is True
|
||||
assert [r["desc"] for r in regular] == ["Serviço Avulso X"]
|
||||
|
||||
processor._annotate_class(strategic, "Itens Eventuais")
|
||||
processor._annotate_class(regular, "Itens Eventuais")
|
||||
assert strategic[0]["classe"] == "estrategico"
|
||||
assert strategic[0]["verb"] == "falar sobre"
|
||||
assert regular[0]["classe"] == "avulso"
|
||||
125
tests/migration/original_test_input_size.py
Normal file
125
tests/migration/original_test_input_size.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.calibrated.input_size import (
|
||||
_count_tokens,
|
||||
_max_tokens,
|
||||
verificar_tamanho_input,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _count_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_count_tokens_texto_vazio():
|
||||
assert _count_tokens("") == 1
|
||||
|
||||
|
||||
def test_count_tokens_texto_curto():
|
||||
assert _count_tokens("abcd") == 1
|
||||
|
||||
|
||||
def test_count_tokens_estimativa():
|
||||
assert _count_tokens("a" * 400) == 100
|
||||
|
||||
|
||||
def test_count_tokens_exato_no_cap():
|
||||
# 4096 tokens * 4 chars = 16384 chars → estimativa == 4096
|
||||
assert _count_tokens("a" * 16384) == 4096
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _max_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_max_tokens_default_quando_ausente(monkeypatch):
|
||||
monkeypatch.delenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", raising=False)
|
||||
assert _max_tokens() == 4096
|
||||
|
||||
|
||||
def test_max_tokens_lido_do_env(monkeypatch):
|
||||
monkeypatch.setenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "1000")
|
||||
assert _max_tokens() == 1000
|
||||
|
||||
|
||||
def test_max_tokens_invalido_usa_default(monkeypatch):
|
||||
monkeypatch.setenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "nao_e_numero")
|
||||
assert _max_tokens() == 4096
|
||||
|
||||
|
||||
def test_max_tokens_zero_usa_default(monkeypatch):
|
||||
monkeypatch.setenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "0")
|
||||
assert _max_tokens() == 4096
|
||||
|
||||
|
||||
def test_max_tokens_negativo_usa_default(monkeypatch):
|
||||
monkeypatch.setenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "-100")
|
||||
assert _max_tokens() == 4096
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verificar_tamanho_input — inputs permitidos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_input_curto_passa():
|
||||
r = verificar_tamanho_input("qual o valor da minha fatura?")
|
||||
assert r.allowed is True
|
||||
assert r.code == "INPUT_SIZE"
|
||||
assert r.mechanism == "deterministic"
|
||||
assert r.data["estimated_tokens"] <= r.data["max_tokens"]
|
||||
|
||||
|
||||
def test_input_vazio_passa():
|
||||
r = verificar_tamanho_input("")
|
||||
assert r.allowed is True
|
||||
|
||||
|
||||
def test_input_exatamente_no_limite_passa(monkeypatch):
|
||||
monkeypatch.setenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "10")
|
||||
# 10 tokens * 4 chars = 40 chars → estimativa == 10 (no limite)
|
||||
r = verificar_tamanho_input("a" * 40)
|
||||
assert r.allowed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verificar_tamanho_input — inputs bloqueados
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_input_gigante_bloqueia():
|
||||
texto_grande = "a" * 100_000
|
||||
r = verificar_tamanho_input(texto_grande)
|
||||
assert r.allowed is False
|
||||
assert r.code == "INPUT_SIZE"
|
||||
assert r.mechanism == "deterministic"
|
||||
assert "excede limite" in r.reason
|
||||
|
||||
|
||||
def test_input_bloqueado_retorna_dados_corretos():
|
||||
texto_grande = "x" * 20_000
|
||||
r = verificar_tamanho_input(texto_grande)
|
||||
assert r.allowed is False
|
||||
assert r.data["estimated_tokens"] > r.data["max_tokens"]
|
||||
assert r.data["len_chars"] == 20_000
|
||||
|
||||
|
||||
def test_input_um_token_acima_do_cap_bloqueia(monkeypatch):
|
||||
monkeypatch.setenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "10")
|
||||
# 11 tokens * 4 chars = 44 chars → estimativa == 11 (acima do cap 10)
|
||||
r = verificar_tamanho_input("a" * 44)
|
||||
assert r.allowed is False
|
||||
|
||||
|
||||
def test_input_bloqueado_preserva_texto_original():
|
||||
texto = "a" * 100_000
|
||||
r = verificar_tamanho_input(texto)
|
||||
assert r.sanitized_text == texto
|
||||
|
||||
|
||||
def test_cap_customizado_via_env(monkeypatch):
|
||||
monkeypatch.setenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "100")
|
||||
texto_medio = "a" * 500 # 125 tokens estimados → acima de 100
|
||||
r = verificar_tamanho_input(texto_medio)
|
||||
assert r.allowed is False
|
||||
assert r.data["max_tokens"] == 100
|
||||
1144
tests/migration/original_test_invoice_resolver_full.py
Normal file
1144
tests/migration/original_test_invoice_resolver_full.py
Normal file
File diff suppressed because it is too large
Load Diff
221
tests/migration/original_test_invoice_to_text.py
Normal file
221
tests/migration/original_test_invoice_to_text.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from app.domain.contas.parsers.invoice_to_text import get_vas_product_names, to_text
|
||||
|
||||
|
||||
def _build_lines(text: str) -> list[str]:
|
||||
return text.splitlines()
|
||||
|
||||
|
||||
def test_sva_renderiza_acao_por_item() -> None:
|
||||
"""SVA: header "Itens Eventuais | cobrados a parte" (a seção "SVA Detalhe Total"
|
||||
é impressa sob esse rótulo); estrategico -> 'falar sobre'; avulso -> 'cancelar'."""
|
||||
data = {
|
||||
"11999990000": {
|
||||
"SVA Detalhe Total": [
|
||||
{"desc": "Aluguel de Filme 1", "value": 19.90, "period": "02/05/26"},
|
||||
{
|
||||
"desc": "YouTube Premium Mensal",
|
||||
"value": 24.90,
|
||||
"period": "02/05/26",
|
||||
"estrategico": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
lines = _build_lines(to_text(data))
|
||||
|
||||
# header "cobrados a parte", sem "| ação=... | avulso"
|
||||
assert "Itens Eventuais | cobrados a parte" in lines
|
||||
assert "SVA Detalhe Total" not in to_text(data)
|
||||
|
||||
avulso = next(l for l in lines if "Aluguel de Filme 1" in l)
|
||||
estrategico = next(l for l in lines if "YouTube Premium Mensal" in l)
|
||||
|
||||
assert avulso.endswith("| ação=cancelar")
|
||||
# Item estratégico no SVA: ação "falar sobre" + marcador "type=estrategico".
|
||||
assert "| ação=falar sobre" in estrategico
|
||||
assert estrategico.endswith("| type=estrategico")
|
||||
|
||||
|
||||
def test_itens_eventuais_cobrados_a_parte_acao_por_item() -> None:
|
||||
"""Itens Eventuais: header "cobrados a parte" + ação por item (avulso ->
|
||||
'cancelar'; estratégico -> 'falar sobre' + 'type=estrategico')."""
|
||||
data = {
|
||||
"11999990000": {
|
||||
"Itens Eventuais": [
|
||||
{"desc": "Serviço Avulso X", "value": 9.90, "period": "02/05/26"},
|
||||
{
|
||||
"desc": "Max Mensal",
|
||||
"value": 34.90,
|
||||
"period": "02/05/26",
|
||||
"estrategico": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
lines = _build_lines(to_text(data))
|
||||
|
||||
assert "Itens Eventuais | cobrados a parte" in lines
|
||||
assert "Itens Eventuais | ação=cancelar | avulso" not in lines
|
||||
|
||||
avulso = next(l for l in lines if "Serviço Avulso X" in l)
|
||||
estrategico = next(l for l in lines if "Max Mensal" in l)
|
||||
|
||||
assert avulso.endswith("| ação=cancelar")
|
||||
assert "| ação=falar sobre" in estrategico
|
||||
assert estrategico.endswith("| type=estrategico")
|
||||
|
||||
|
||||
def test_sva_e_itens_eventuais_mesclam_num_unico_cabecalho() -> None:
|
||||
"""Linha com as DUAS seções ("SVA Detalhe Total" e "Itens Eventuais"): um único
|
||||
cabeçalho "Itens Eventuais", na posição da primeira delas, com os itens das duas
|
||||
embaixo. A ação por item continua vindo da seção de ORIGEM."""
|
||||
data = {
|
||||
"11999990000": {
|
||||
"SVA Detalhe Total": [
|
||||
{"desc": "Tamboro Mensal", "value": 14.99, "period": "01/11/25"},
|
||||
],
|
||||
"Mensalidades Adicionais": [{"desc": "Plugin 5G Plus", "value": 5.00}],
|
||||
"Itens Eventuais": [
|
||||
{"desc": "Serviço Avulso X", "value": 9.90, "period": "02/05/26"},
|
||||
{"desc": "CRÉDITO: PAGAMENTO", "value": -20.00},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
full = to_text(data)
|
||||
lines = _build_lines(full)
|
||||
|
||||
assert lines.count("Itens Eventuais | cobrados a parte") == 1
|
||||
assert "SVA Detalhe Total" not in full
|
||||
# Posição: o bloco mesclado fica onde a PRIMEIRA das duas seções aparecia,
|
||||
# antes de "Mensalidades Adicionais".
|
||||
assert [l for l in lines if not l[:1].isspace() and l] == [
|
||||
"LINHA 11999990000",
|
||||
"Itens Eventuais | cobrados a parte",
|
||||
"Mensalidades Adicionais",
|
||||
"multiplas_linhas = false",
|
||||
]
|
||||
assert " Tamboro Mensal | valor=14.99 | data=01/11/25 | ação=cancelar" in lines
|
||||
assert " Serviço Avulso X | valor=9.90 | data=02/05/26 | ação=cancelar" in lines
|
||||
assert " CRÉDITO: PAGAMENTO | valor=-20.00 | ação=cancelar" in lines
|
||||
|
||||
# No modo enxuto o cabeçalho mesclado é só o rótulo (sem "| cobrados a parte").
|
||||
lean_lines = _build_lines(to_text(data, lean=True))
|
||||
assert lean_lines.count("Itens Eventuais") == 1
|
||||
assert " Tamboro Mensal | valor=14.99 | data=01/11/25" in lean_lines
|
||||
assert " Serviço Avulso X | valor=9.90 | data=02/05/26" in lean_lines
|
||||
|
||||
|
||||
def test_lean_enxuga_para_o_intent() -> None:
|
||||
"""``lean=True`` (formato do intent classifier): mantém LINHA/seção, o NOME dos
|
||||
itens e ``valor``/``data`` por item (para desambiguar cobranças duplicadas);
|
||||
descarta Fatura Resumo, Planos, Outros Valores e os demais campos por-item
|
||||
(ação/type/franquia/consumo). Item ``Incluído`` e período em faixa não geram
|
||||
valor/data."""
|
||||
data = {
|
||||
"Fatura Resumo": [{"desc": "total geral", "value": 207.43}],
|
||||
"1199999999": {
|
||||
"Planos": {"TIM Black A 8.0": {"valor_final": 104.99}},
|
||||
"Outros Valores": [{"desc": "juros: (VENC 10/11)", "value": 0.26}],
|
||||
"SVA Detalhe Total": [
|
||||
{"desc": "Tamboro Mensal", "value": 14.99, "period": "01/11/25"},
|
||||
{"desc": "Youtube Premium", "value": 10.0, "estrategico": True},
|
||||
],
|
||||
"Serviços Bundle Inclusos": [
|
||||
{"desc": "Minutos Locais e DDD com 41", "value": "Incluído",
|
||||
"period": "19/05 a 18/06", "franchise": "Ilimitado",
|
||||
"consumption": "43m30s"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
lean = to_text(data, lean=True)
|
||||
lines = _build_lines(lean)
|
||||
|
||||
# Estrutura + nome + valor/data por item (data só p/ período de data única).
|
||||
assert "LINHA 1199999999" in lines
|
||||
assert " Tamboro Mensal | valor=14.99 | data=01/11/25" in lines
|
||||
# Estratégico sem período: valor sim, data não.
|
||||
assert " Youtube Premium | valor=10.00" in lines
|
||||
# Bundle "Incluído" com período em faixa: só o nome (sem valor/data).
|
||||
assert " Minutos Locais e DDD com 41" in lines
|
||||
assert "multiplas_linhas = false" in lines
|
||||
# Cabeçalho de seção é só o nome (sem "| ação=… | classe") no modo enxuto.
|
||||
assert "Benefícios do Plano" in lines
|
||||
assert "ação=" not in lean
|
||||
assert "| bundle" not in lean
|
||||
# valor/data por item agora PRESENTES (distinguem cobranças duplicadas).
|
||||
assert "valor=14.99" in lean
|
||||
assert "data=01/11/25" in lean
|
||||
# "Incluído" nunca vira valor=; período em faixa nunca vira data=.
|
||||
assert "valor=Incluído" not in lean
|
||||
assert "data=19/05" not in lean
|
||||
# Descartado: resumo, planos, outros valores e os demais campos por-item.
|
||||
assert "Fatura Resumo" not in lean
|
||||
assert "Planos" not in lean
|
||||
assert "Outros Valores" not in lean
|
||||
assert "TIM Black A 8.0" not in lean
|
||||
assert "type=estrategico" not in lean
|
||||
assert "franquia=" not in lean
|
||||
assert "consumo=" not in lean
|
||||
|
||||
# O formato completo (default) SEGUE com tudo — orquestrador inalterado.
|
||||
full = to_text(data)
|
||||
assert "Fatura Resumo" in full
|
||||
assert "Planos" in full
|
||||
assert "valor=14.99" in full
|
||||
|
||||
|
||||
def test_get_vas_product_names_ordem_e_filtra_nao_vas() -> None:
|
||||
"""Só VAS, na ordem avulso -> estrategico -> bundle; plano e não-VAS ficam de
|
||||
fora. `classe` é a fonte; item com só `estrategico=True` (seção sem classe) cai
|
||||
no fallback estratégico."""
|
||||
data = {
|
||||
"Fatura Resumo": [{"desc": "total geral", "value": 100.0}],
|
||||
"vocalized_msisdn": {"11999990000": "um um ..."},
|
||||
"11999990000": {
|
||||
"Planos": {"TIM Black A 8.0": {"valor_final": 100.0}},
|
||||
"SVA Detalhe Total": [
|
||||
{"desc": "Focus Mensal", "value": 14.99, "classe": "avulso"},
|
||||
{"desc": "Netflix", "value": 39.9, "classe": "estrategico",
|
||||
"estrategico": True},
|
||||
],
|
||||
"Itens Eventuais": [
|
||||
{"desc": "Tamboro Mensal", "value": 9.9, "classe": "avulso"},
|
||||
],
|
||||
"Serviços Bundle Inclusos": [
|
||||
{"desc": "HBO Bundle", "classe": "bundle"},
|
||||
],
|
||||
# seção sem `classe`, só a flag -> fallback estratégico.
|
||||
"Cobranças de Terceiros": [
|
||||
{"desc": "Globoplay", "value": 19.9, "estrategico": True},
|
||||
],
|
||||
"Descontos": [{"desc": "Desconto Fidelidade", "value": -10.0}],
|
||||
},
|
||||
}
|
||||
|
||||
result = get_vas_product_names(data)
|
||||
|
||||
assert result == "Focus Mensal, Tamboro Mensal, Netflix, Globoplay, HBO Bundle"
|
||||
# plano e seção não-VAS não entram.
|
||||
assert "TIM Black" not in result
|
||||
assert "Desconto" not in result
|
||||
|
||||
|
||||
def test_get_vas_product_names_deduplica_entre_linhas() -> None:
|
||||
"""Mesmo produto em duas linhas (MSISDN) aparece 1×, na primeira ocorrência."""
|
||||
data = {
|
||||
"11999990000": {"SVA Detalhe Total": [{"desc": "Focus Mensal", "classe": "avulso"}]},
|
||||
"11888880000": {"SVA Detalhe Total": [{"desc": "Focus Mensal", "classe": "avulso"}]},
|
||||
}
|
||||
|
||||
assert get_vas_product_names(data) == "Focus Mensal"
|
||||
|
||||
|
||||
def test_get_vas_product_names_sem_vas_retorna_vazio() -> None:
|
||||
assert get_vas_product_names({}) == ""
|
||||
assert get_vas_product_names(
|
||||
{"11999990000": {"Descontos": [{"desc": "X", "value": -1.0}]}}
|
||||
) == ""
|
||||
44
tests/migration/original_test_out_of_scope.py
Normal file
44
tests/migration/original_test_out_of_scope.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Testes do contrato de saída do rail OOS.
|
||||
|
||||
O `label` ("IN_SCOPE"/"OUT_OF_SCOPE") saiu do prompt em 2026-08-05: nenhum
|
||||
consumidor de produção o lia (a decisão usa `allowed` + `code`, fixado no
|
||||
pipeline) e ele era gerado a cada turno. O `reason` passou a vir VAZIO quando
|
||||
`allowed=true`, como no FRASEOLOGIA — descrever por que algo está DENTRO do
|
||||
escopo não é lido por ninguém.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("USE_MOCK_LLM", "true")
|
||||
|
||||
from agent_framework.guardrails.calibrated.llm_rails import out_of_scope
|
||||
from agent_framework.guardrails.calibrated.prompts.out_of_scope import build_oos_prompt
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_mock(monkeypatch):
|
||||
monkeypatch.setenv("USE_MOCK_LLM", "true")
|
||||
|
||||
|
||||
def test_prompt_oos_nao_pede_mais_label():
|
||||
prompt = build_oos_prompt("Posso ajudar com sua fatura?")
|
||||
assert '"label"' not in prompt
|
||||
assert "IN_SCOPE" not in prompt.split("Responda APENAS JSON valido:")[-1]
|
||||
|
||||
|
||||
def test_prompt_oos_exige_reason_vazio_quando_allowed():
|
||||
prompt = build_oos_prompt("Posso ajudar com sua fatura?")
|
||||
contrato = prompt.split("Responda APENAS JSON valido:")[-1]
|
||||
assert "se allowed=true: string vazia" in contrato
|
||||
# e os exemplos de IN_SCOPE seguem a mesma regra
|
||||
assert '{"allowed": true, "reason": ""}' in prompt
|
||||
|
||||
|
||||
def test_oos_rail_devolve_shape_esperado():
|
||||
r = out_of_scope("Posso te ajudar com mais alguma dúvida sobre a fatura?")
|
||||
assert r.code == "OOS"
|
||||
assert r.mechanism == "llm_supervisor"
|
||||
assert isinstance(r.allowed, bool)
|
||||
361
tests/migration/test_adversarial_parity.py
Normal file
361
tests/migration/test_adversarial_parity.py
Normal file
@@ -0,0 +1,361 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.channels.interruption import evaluate_interruption
|
||||
from agent_framework.guardrails.calibrated.contracts import GuardRailContext
|
||||
from agent_framework.guardrails.calibrated.rails.anatel import AnatelRail
|
||||
|
||||
from app.domain.contas.vas_variation import VariedCharge, varied_current_charges
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
PAST = "2026-03-10T00:00:00.000Z"
|
||||
CURRENT = "2026-04-07T00:00:00.000Z"
|
||||
|
||||
|
||||
def _group(*items: dict, type_: str = "servicos_contratados_de_parceiros") -> dict:
|
||||
return {"type": type_, "desc": "Serviços de valor adicionado", "items": list(items)}
|
||||
|
||||
|
||||
def test_vas_variation_preserva_multiset_e_direcao_atual():
|
||||
payload = {"invoiceVariation": [
|
||||
_group(
|
||||
{"desc": "Qualifica Mensal", "value": "19.99", "invoice": PAST},
|
||||
{"desc": "Qualifica Mensal", "value": "19.99", "invoice": CURRENT},
|
||||
{"desc": "Qualifica Mensal", "value": "19.99", "invoice": CURRENT},
|
||||
)
|
||||
]}
|
||||
result = varied_current_charges(payload)
|
||||
assert result == (VariedCharge(desc="Qualifica Mensal", value=result[0].value),)
|
||||
assert str(result[0].value) == "19.99"
|
||||
|
||||
|
||||
def test_vas_variation_streaming_nao_entra_no_recorte_avulso_padrao():
|
||||
payload = {"invoiceVariation": [
|
||||
_group(
|
||||
{"desc": "Netflix Padrão", "value": "20.90", "invoice": CURRENT},
|
||||
type_="streaming",
|
||||
)
|
||||
]}
|
||||
assert varied_current_charges(payload) == ()
|
||||
|
||||
|
||||
def test_post_finalize_replay_e_short_circuit_framework():
|
||||
decision = evaluate_interruption(
|
||||
payload={},
|
||||
message_text="alô, ainda está aí?",
|
||||
session_metadata={
|
||||
"conversation_closed": True,
|
||||
"terminal_status": "nao_resolvido",
|
||||
"last_assistant_text": "Aguarde um instante na linha. Protocolo: 2026492272073",
|
||||
"last_assistant_is_interruptible": True,
|
||||
},
|
||||
)
|
||||
assert decision.action == "replay"
|
||||
assert decision.reason == "post_finalize"
|
||||
assert "2026492272073" in decision.replay_text
|
||||
assert decision.is_interruptible is False
|
||||
|
||||
|
||||
def test_anatel_rail_e_framework_native_sem_dependencia_legado():
|
||||
rail = AnatelRail()
|
||||
blocked = rail.evaluate(
|
||||
GuardRailContext(
|
||||
session_id="s1",
|
||||
user_text="O ajuste foi concluído.",
|
||||
agent_metadata={
|
||||
"tipo_fluxo": "ajuste",
|
||||
"requer_protocolo": True,
|
||||
"expected_protocols": ["2026492272073"],
|
||||
},
|
||||
)
|
||||
)
|
||||
assert blocked.allowed is False
|
||||
assert blocked.sanitized_text
|
||||
assert "protocolo" in blocked.sanitized_text.lower()
|
||||
|
||||
approved = rail.evaluate(
|
||||
GuardRailContext(
|
||||
session_id="s1",
|
||||
user_text="Ajuste concluído. Protocolo: 2026492272073.",
|
||||
agent_metadata={"tipo_fluxo": "ajuste"},
|
||||
)
|
||||
)
|
||||
assert approved.allowed is True
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self):
|
||||
self.contestar_calls = 0
|
||||
|
||||
def billing_analysis(self, msisdn: str):
|
||||
return {
|
||||
"Servicos de Valor Adicionado": [
|
||||
{"desc": "Pacote Musica", "valor": "10,00"}
|
||||
]
|
||||
}
|
||||
|
||||
def contestar(self, payload):
|
||||
self.contestar_calls += 1
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class _FakeService:
|
||||
def __init__(self):
|
||||
self.client = _FakeClient()
|
||||
|
||||
|
||||
def test_cval_bloqueia_contestacao_antes_do_side_effect():
|
||||
service = _FakeService()
|
||||
reg = build_contas_workflow_actions(service) # type: ignore[arg-type]
|
||||
action = reg.get("abrir_contestacao_cliente")
|
||||
result = action(
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"items": [
|
||||
{
|
||||
"itemName": "Pacote nao cobrado",
|
||||
"claimedAmount": "10",
|
||||
"validatedAmount": "10",
|
||||
}
|
||||
],
|
||||
},
|
||||
{"input": {}},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["blocked"] is True
|
||||
assert result["guardrail_code"] == "CVAL"
|
||||
assert service.client.contestar_calls == 0
|
||||
|
||||
from app.domain.contas.item_matcher import SimilarityItemMatcher
|
||||
|
||||
|
||||
def test_similarity_matcher_recupera_erros_de_transcricao_comuns():
|
||||
matcher = SimilarityItemMatcher()
|
||||
candidates = [
|
||||
"Tamboro Mensal",
|
||||
"Netflix Padrão",
|
||||
"Truecaller Semanal",
|
||||
"MasterChef Mensal",
|
||||
"Paramount+ Mensal",
|
||||
]
|
||||
assert matcher.match("mestershef", candidates)[0] == "MasterChef Mensal"
|
||||
assert matcher.match("trucaller", candidates)[0] == "Truecaller Semanal"
|
||||
assert matcher.match("paramaunt", candidates)[0] == "Paramount+ Mensal"
|
||||
|
||||
|
||||
def test_tool_clarification_aceita_opcao_ordinal():
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
options = [
|
||||
{"label": "Tamboro Mensal — linha final 1111", "value": "Tamboro Mensal"},
|
||||
{"label": "Tamboro Mensal — linha final 2222", "value": "Tamboro Mensal"},
|
||||
]
|
||||
selected = AgentRuntimeMixin._choose_tool_clarification_option("2", options)
|
||||
assert selected == options[1]
|
||||
selected_by_name = AgentRuntimeMixin._choose_tool_clarification_option(
|
||||
"Tamboro Mensal — linha final 1111", options
|
||||
)
|
||||
assert selected_by_name == options[0]
|
||||
|
||||
|
||||
def test_output_guardrail_context_extrai_protocolos_das_tools():
|
||||
from app.workflows.agent_graph import AgentWorkflow
|
||||
|
||||
ctx = AgentWorkflow._output_guardrail_context(
|
||||
{
|
||||
"context": {},
|
||||
"mcp_results": [
|
||||
{
|
||||
"ok": True,
|
||||
"result": {
|
||||
"result": {
|
||||
"protocolo_id": "2026492272073",
|
||||
"nested": {"protocolNumber": "2026492272074"},
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
assert ctx["requer_protocolo"] is True
|
||||
assert ctx["tipo_fluxo"] == "ajuste"
|
||||
assert ctx["expected_protocols"] == ["2026492272073", "2026492272074"]
|
||||
assert ctx["tool_executed"] is True
|
||||
|
||||
|
||||
def test_fix_whole_utterance_transcription_preserva_fronteira_de_frase():
|
||||
from agent_framework.channels.transcription import fix_whole_utterance_transcription
|
||||
|
||||
assert fix_whole_utterance_transcription("Fim") == "Sim"
|
||||
assert fix_whole_utterance_transcription("Mim?") == "Sim"
|
||||
assert fix_whole_utterance_transcription("chegou ao fim") == "chegou ao fim"
|
||||
assert fix_whole_utterance_transcription("isso é pra mim") == "isso é pra mim"
|
||||
|
||||
|
||||
def test_post_finalize_replay_preserva_terminal_status_e_fallback_sem_historico():
|
||||
decision = evaluate_interruption(
|
||||
payload={},
|
||||
message_text="oi",
|
||||
session_metadata={
|
||||
"conversation_closed": True,
|
||||
"terminal_status": "nao_resolvido",
|
||||
"last_assistant_text": "",
|
||||
"terminal_replay_text": "",
|
||||
},
|
||||
terminal_fallback_text=(
|
||||
"Por aqui finalizamos o tratamento da sua solicitação. "
|
||||
"Aguarde um instante na linha."
|
||||
),
|
||||
)
|
||||
assert decision.action == "replay"
|
||||
assert decision.reason == "post_finalize"
|
||||
assert decision.terminal_status == "nao_resolvido"
|
||||
assert decision.replay_text.startswith("Por aqui finalizamos")
|
||||
assert decision.is_interruptible is False
|
||||
|
||||
|
||||
def test_post_finalize_replay_status_invalido_usa_fallback_seguro():
|
||||
decision = evaluate_interruption(
|
||||
payload={},
|
||||
message_text="oi",
|
||||
session_metadata={"conversation_closed": True},
|
||||
terminal_fallback_text="Atendimento já encerrado.",
|
||||
)
|
||||
assert decision.action == "replay"
|
||||
assert decision.terminal_status == "erro_falha_sistema"
|
||||
assert decision.replay_text == "Atendimento já encerrado."
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entrada", "esperado"),
|
||||
[
|
||||
("Fim", "Sim"),
|
||||
("fim", "Sim"),
|
||||
("FIM", "Sim"),
|
||||
("Fim.", "Sim"),
|
||||
(" fim ", "Sim"),
|
||||
("fim!", "Sim"),
|
||||
("Mim", "Sim"),
|
||||
("mim", "Sim"),
|
||||
("Mim?", "Sim"),
|
||||
("chegou ao fim", "chegou ao fim"),
|
||||
("isso é pra mim", "isso é pra mim"),
|
||||
("fim de semana", "fim de semana"),
|
||||
("Fim, pode encerrar", "Fim, pode encerrar"),
|
||||
("mim mesmo", "mim mesmo"),
|
||||
("Sim", "Sim"),
|
||||
("não", "não"),
|
||||
("", ""),
|
||||
(" ", " "),
|
||||
("cancela o Tamboro", "cancela o Tamboro"),
|
||||
],
|
||||
)
|
||||
def test_transcription_fix_paridade_original(entrada: str, esperado: str):
|
||||
from agent_framework.channels.transcription import fix_whole_utterance_transcription
|
||||
|
||||
assert fix_whole_utterance_transcription(entrada) == esperado
|
||||
|
||||
|
||||
class _ClassifierResponse:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
|
||||
|
||||
class _ClassifierLLM:
|
||||
def __init__(self, content: str = "0", *, fail: bool = False):
|
||||
self.content = content
|
||||
self.fail = fail
|
||||
self.calls = []
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
self.calls.append((messages, kwargs))
|
||||
if self.fail:
|
||||
raise RuntimeError("classifier unavailable")
|
||||
return _ClassifierResponse(self.content)
|
||||
|
||||
|
||||
def test_processing_interruption_interrompivel_pede_classificacao():
|
||||
decision = evaluate_interruption(
|
||||
payload={"processing_interruption": {"heard_text": "cancelar também"}},
|
||||
message_text="cancelar também",
|
||||
session_metadata={
|
||||
"last_assistant_text": "Vou verificar sua fatura.",
|
||||
"last_assistant_is_interruptible": True,
|
||||
},
|
||||
)
|
||||
assert decision.action == "classify"
|
||||
assert decision.replay_text == "Vou verificar sua fatura."
|
||||
assert decision.heard_text == "cancelar também"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_interruption_classifier_resultado_1_reprocessa():
|
||||
from agent_framework.channels.interruption import classify_processing_interruption
|
||||
|
||||
llm = _ClassifierLLM("1")
|
||||
result = await classify_processing_interruption(
|
||||
llm,
|
||||
original_agent="Vou orientar sobre o cancelamento.",
|
||||
original_client="Quero cancelar VOD e o",
|
||||
supplement_client="Aluguel de Filme 1.",
|
||||
)
|
||||
assert result is True
|
||||
assert llm.calls
|
||||
_, kwargs = llm.calls[0]
|
||||
assert kwargs["profile_name"] == "processing_interruption_classifier"
|
||||
assert kwargs["component_name"] == "processing_interruption_classifier"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processing_interruption_classifier_falha_faz_replay_seguro():
|
||||
from agent_framework.channels.interruption import classify_processing_interruption
|
||||
|
||||
llm = _ClassifierLLM(fail=True)
|
||||
result = await classify_processing_interruption(
|
||||
llm,
|
||||
original_agent="Aqui vai a resposta.",
|
||||
original_client="quanto custa?",
|
||||
supplement_client="oi",
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
|
||||
def test_finalizacao_informacional_infer_tipo_pela_fatura():
|
||||
from app.domain.contas.workflow_actions import _infer_informational_vas_types
|
||||
|
||||
invoice_detail = {
|
||||
"11999999999": {
|
||||
"Serviços Bundle Inclusos": [{"desc": "EXA Cloud", "value": 0}],
|
||||
"Serviços Contratados de Terceiros": [{"desc": "Netflix Premium", "value": 59.9}],
|
||||
"SVA Detalhe Total": [{"desc": "Aluguel de Filme 2", "value": 19.9}],
|
||||
}
|
||||
}
|
||||
assert _infer_informational_vas_types(
|
||||
["EXA Cloud", "Netflix Premium", "Aluguel de Filme 2"], invoice_detail
|
||||
) == {"bundle", "estrategico", "avulso"}
|
||||
assert _infer_informational_vas_types(["exa clod"], invoice_detail) == {"bundle"}
|
||||
|
||||
|
||||
def test_finalizacao_informacional_infer_tipo_de_billing_analysis_json():
|
||||
import json
|
||||
from app.domain.contas.workflow_actions import _infer_informational_vas_types
|
||||
payload = {
|
||||
"currentInvoice": [
|
||||
{"desc": "Serviços de valor adicionado", "items": [
|
||||
{"contestable": True, "desc": "Aluguel de Filme 3", "type": "servicos_contratados_de_parceiros"},
|
||||
{"contestable": False, "desc": "VOD + Canais Abertos", "type": "servicos_contratados_de_parceiros"},
|
||||
]},
|
||||
{"desc": "Streamings", "items": [
|
||||
{"contestable": False, "desc": "YouTube Premium Mensal", "type": "streaming"}
|
||||
]},
|
||||
]
|
||||
}
|
||||
assert _infer_informational_vas_types(
|
||||
["YouTube Premium Mensal", "Aluguel de Filme 3"], json.dumps(payload, ensure_ascii=False)
|
||||
) == {"estrategico", "avulso"}
|
||||
314
tests/migration/test_backend_wrapper_contract_parity.py
Normal file
314
tests/migration/test_backend_wrapper_contract_parity.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from contas_mcp.servers.contas_mcp_server import main as mcp_main
|
||||
|
||||
|
||||
def _result(*, status: str = "COMPLETED", output: dict[str, Any] | None = None,
|
||||
error: str | None = None, trace: list[dict[str, Any]] | None = None,
|
||||
error_details: dict[str, Any] | None = None) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
execution_id="exec-test",
|
||||
status=status,
|
||||
output=output or {},
|
||||
error=error,
|
||||
trace=trace or [],
|
||||
state={},
|
||||
error_details=error_details,
|
||||
model_dump=lambda: {
|
||||
"execution_id": "exec-test",
|
||||
"status": status,
|
||||
"output": output or {},
|
||||
"error": error,
|
||||
"trace": trace or [],
|
||||
"state": {},
|
||||
"error_details": error_details,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _Runtime:
|
||||
def __init__(self, *results: Any):
|
||||
self.results = list(results)
|
||||
self.calls: list[tuple[str, dict[str, Any], str | None]] = []
|
||||
|
||||
async def arun(self, name: str, payload: dict[str, Any], execution_id: str | None = None):
|
||||
self.calls.append((name, dict(payload), execution_id))
|
||||
if not self.results:
|
||||
raise AssertionError("resultado não configurado")
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
def test_contestacao_wrapper_preserva_tipo_atendimento_e_contexto_do_turno(monkeypatch):
|
||||
runtime = _Runtime(_result(output={"abrir_contestacao_cliente": {"success": True}}))
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
args = {
|
||||
"msisdn": "15981110720",
|
||||
"subject": "VAS",
|
||||
"valor": "10",
|
||||
"message_id": "msg-123",
|
||||
"customer_message": "Sim",
|
||||
"session_created_at": "2026-05-05T12:00:00+00:00",
|
||||
"ura_call_id": "CALL-123",
|
||||
"channel_id": "URA",
|
||||
"ani": "11900000000",
|
||||
"invoice_id": "3000132705",
|
||||
}
|
||||
result = asyncio.run(mcp_main._run_workflow("contestacao_tool", args))
|
||||
assert result["status"] == "COMPLETED"
|
||||
_, payload, _ = runtime.calls[0]
|
||||
assert payload["tipo_atendimento"] == "contestacao"
|
||||
for key in ("message_id", "customer_message", "session_created_at", "ura_call_id", "channel_id", "ani", "invoice_id"):
|
||||
assert payload[key] == args[key]
|
||||
|
||||
|
||||
def test_contestacao_wrapper_mapeia_falha_tecnica_como_sistemica(monkeypatch):
|
||||
runtime = _Runtime(_result(status="FAILED", error="gateway indisponivel"))
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
result = asyncio.run(mcp_main._run_workflow("contestacao_tool", {"msisdn": "62999999999", "subject": "VAS"}))
|
||||
assert result["success"] is False
|
||||
assert result["erro_sistemico"] is True
|
||||
assert result["erro"] == "erro_falha_sistema"
|
||||
assert "contestation_error_description" not in result
|
||||
|
||||
|
||||
def test_contestacao_wrapper_preserva_erro_de_negocio_e_protocolo(monkeypatch):
|
||||
msg = "Impossível prosseguir, pois não existe os itens informados na fatura questionada."
|
||||
runtime = _Runtime(_result(
|
||||
status="FAILED",
|
||||
error=msg,
|
||||
output={"registrar_protocolo": {"protocolo_id": "PRT-123"}},
|
||||
error_details={"body": {"description": "Itens já contestados", "provider": {"errorMessage": msg}}},
|
||||
))
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
result = asyncio.run(mcp_main._run_workflow("contestacao_tool", {"msisdn": "62999999999", "subject": "VAS"}))
|
||||
assert result["success"] is False
|
||||
assert result["erro_sistemico"] is False
|
||||
assert result["contestation_error_description"] == msg
|
||||
assert result["mensagem"] == msg
|
||||
assert result["contestacao_protocol"] == "PRT-123"
|
||||
|
||||
|
||||
def test_invoice_explanation_branch_nao_sem_vas_recomenda_nao_resolvido():
|
||||
result = mcp_main._result_payload(
|
||||
_result(trace=[{"node": "finalizar_nao_resolvido"}]),
|
||||
workflow_name="invoice_explanation",
|
||||
)
|
||||
assert result["recomenda_finalizacao"] is True
|
||||
assert result["status_finalizacao_sugerido"] == "nao_resolvido"
|
||||
|
||||
|
||||
def test_invoice_explanation_branch_devolver_orquestrador_nao_auto_finaliza():
|
||||
result = mcp_main._result_payload(
|
||||
_result(trace=[{"node": "devolver_orquestrador"}]),
|
||||
workflow_name="invoice_explanation",
|
||||
)
|
||||
assert "recomenda_finalizacao" not in result
|
||||
assert "status_finalizacao_sugerido" not in result
|
||||
|
||||
|
||||
def test_pro_rata_branch_devolver_orquestrador_nao_auto_finaliza():
|
||||
result = mcp_main._result_payload(
|
||||
_result(trace=[{"node": "devolver_orquestrador"}]),
|
||||
workflow_name="pro_rata",
|
||||
)
|
||||
assert "recomenda_finalizacao" not in result
|
||||
assert "status_finalizacao_sugerido" not in result
|
||||
|
||||
|
||||
def test_pro_rata_aceite_e_nao_controle_recomendam_resolvido():
|
||||
for node in ("registrar_aceitou", "registrar_nao_controle"):
|
||||
result = mcp_main._result_payload(_result(trace=[{"node": node}]), workflow_name="pro_rata")
|
||||
assert result["recomenda_finalizacao"] is True
|
||||
assert result["status_finalizacao_sugerido"] == "resolvido"
|
||||
|
||||
|
||||
def test_vas_estrategico_workflow_payload_preserva_tres_itens_e_action_agrupa_duas_linhas():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Service:
|
||||
pass
|
||||
|
||||
action = build_contas_workflow_actions(Service()).get("preparar_vas_estrategico")
|
||||
items = [
|
||||
{"type": "bundle", "msisdn": "5511999999999", "name": "Netflix"},
|
||||
{"type": "estrategico", "msisdn": "5511999999999", "name": "Aya E-Books"},
|
||||
{"type": "estrategico", "msisdn": "5511988888888", "name": "YouTube Premium"},
|
||||
]
|
||||
payload = mcp_main._workflow_payload("vas_estrategico", {"msisdn": "5511999999999", "items": items})
|
||||
assert len(payload["items"]) == 3
|
||||
result = action(payload, {"input": payload})
|
||||
assert len(result["linhas"]) == 2
|
||||
first = result["linhas"][0]
|
||||
assert first["msisdn"] == "5511999999999"
|
||||
assert "Netflix" in first["bundle_names"]
|
||||
assert "Aya E-Books" in first["estrategico_names"]
|
||||
|
||||
|
||||
def test_vas_estrategico_rejeitado_declara_rag_para_cada_servico_estrategico():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Service:
|
||||
pass
|
||||
|
||||
action = build_contas_workflow_actions(Service()).get("registrar_atendimento_vas_estrategico")
|
||||
params = {
|
||||
"linhas": [
|
||||
{"msisdn": "5511999999999", "bundle_names": ["Netflix"], "estrategico_names": ["Aya E-Books"]},
|
||||
{"msisdn": "5511988888888", "bundle_names": [], "estrategico_names": ["YouTube Premium"]},
|
||||
],
|
||||
"mensagem_base": "concluido",
|
||||
}
|
||||
result = action(params, {"input": {"resposta_usuario": "NAO"}})
|
||||
assert result["requires_rag"] is True
|
||||
assert len(result["rag_queries"]) == 2
|
||||
assert any("Aya E-Books" in q for q in result["rag_queries"])
|
||||
assert any("YouTube Premium" in q for q in result["rag_queries"])
|
||||
|
||||
|
||||
def test_cancelamento_sem_candidatos_nao_dispara_contestacao(monkeypatch):
|
||||
cancel = _result(
|
||||
output={"cancelar_vas_avulso": {
|
||||
"success": True,
|
||||
"cancelados": [{"subject": "VAS X", "success": True}],
|
||||
"contestation_candidates": [],
|
||||
"protocolos_por_linha": [{"protocolo_id": "PRT-CANCEL-1"}],
|
||||
}}
|
||||
)
|
||||
runtime = _Runtime(cancel)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
result = asyncio.run(mcp_main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999", "subject": "VAS X", "items": [{"msisdn": "11999999999", "name": "VAS X"}],
|
||||
}))
|
||||
assert len(runtime.calls) == 1
|
||||
assert result["success"] is True
|
||||
assert result["cancelamento_vas_protocol"] == "PRT-CANCEL-1"
|
||||
|
||||
|
||||
def test_cancelamento_falha_operacional_com_candidato_dispara_contestacao(monkeypatch):
|
||||
cancel = _result(output={"cancelar_vas_avulso": {
|
||||
"success": False,
|
||||
"cancelados": [],
|
||||
"nao_cancelados": [{"subject": "VAS X", "success": False, "error": "block_vas_failed"}],
|
||||
"contestation_candidates": [{"subject": "VAS X", "msisdn": "11999999999", "value": "10.00", "eligible_for_contestation": True}],
|
||||
"protocolos_por_linha": [{"protocolo_id": "PRT-CANCEL-1"}],
|
||||
}})
|
||||
contest = _result(output={
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-CONT-1"},
|
||||
"abrir_contestacao_cliente": {"success": True, "items_response": [{"status": "CRIAR", "itemName": "VAS X"}]},
|
||||
"enviar_sms": {"success": True},
|
||||
})
|
||||
runtime = _Runtime(cancel, contest)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
result = asyncio.run(mcp_main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999", "subject": "VAS X", "items": [{"msisdn": "11999999999", "name": "VAS X", "value": "10.00"}],
|
||||
"complete_invoices_payload": {"billingProfile": {"customer": {"document": "123", "customerId": "C1"}}, "paymentItems": [{"invoiceId": "I1"}]},
|
||||
}))
|
||||
assert [c[0] for c in runtime.calls] == ["cancelamento_vas_avulso", "contestacao_tool"]
|
||||
assert result["contestacao_protocol"] == "PRT-CONT-1"
|
||||
assert result["cancelamento_vas_protocol"] == "PRT-CANCEL-1"
|
||||
|
||||
|
||||
def test_cancelamento_sms_falho_preserva_sucesso_e_flag(monkeypatch):
|
||||
cancel = _result(output={"cancelar_vas_avulso": {
|
||||
"success": True,
|
||||
"cancelados": [{"subject": "VAS X", "success": True}],
|
||||
"contestation_candidates": [{"subject": "VAS X", "msisdn": "11999999999", "value": "10.00"}],
|
||||
"protocolos_por_linha": [{"protocolo_id": "PRT-CANCEL-1"}],
|
||||
}})
|
||||
contest = _result(output={
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-CONT-1"},
|
||||
"abrir_contestacao_cliente": {"success": True, "items_response": [{"status": "CRIAR", "itemName": "VAS X"}]},
|
||||
"enviar_sms": {"success": False, "error": "sms indisponível"},
|
||||
})
|
||||
runtime = _Runtime(cancel, contest)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
result = asyncio.run(mcp_main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999", "subject": "VAS X", "items": [{"msisdn": "11999999999", "name": "VAS X", "value": "10.00"}],
|
||||
"complete_invoices_payload": {"billingProfile": {"customer": {"document": "123", "customerId": "C1"}}, "paymentItems": [{"invoiceId": "I1"}]},
|
||||
}))
|
||||
assert result["success"] is True
|
||||
assert result["sms_not_send_error"] is True
|
||||
|
||||
|
||||
def test_cancelamento_aceita_cpf_como_alias_social_sec_no(monkeypatch):
|
||||
cancel = _result(output={"cancelar_vas_avulso": {
|
||||
"success": True,
|
||||
"cancelados": [{"servico": "VAS X"}],
|
||||
"contestation_candidates": [{"subject": "VAS X", "msisdn": "11999999999", "value": "10.00"}],
|
||||
"protocolos_por_linha": [{"protocolo_id": "PRT-CANCEL-1"}],
|
||||
}})
|
||||
contest = _result(output={
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-CONT-1"},
|
||||
"abrir_contestacao_cliente": {"success": True},
|
||||
})
|
||||
runtime = _Runtime(cancel, contest)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
asyncio.run(mcp_main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999", "cpf": "123.456.789-01", "subject": "VAS X",
|
||||
"items": [{"msisdn": "11999999999", "name": "VAS X", "value": "10.00"}],
|
||||
"complete_invoices_payload": {"billingProfile": {"customer": {"customerId": "C1"}}, "paymentItems": [{"invoiceId": "I1"}]},
|
||||
}))
|
||||
_, cancel_payload, _ = runtime.calls[0]
|
||||
_, contest_payload, _ = runtime.calls[1]
|
||||
assert cancel_payload["social_sec_no"] == "12345678901"
|
||||
assert contest_payload["social_sec_no"] == "12345678901"
|
||||
|
||||
|
||||
def test_cancelamento_falha_workflow_preserva_output_parcial_nao_cancelados(monkeypatch):
|
||||
failed = _result(
|
||||
status="FAILED",
|
||||
error="Falha técnica ao processar serviços VAS no IU.",
|
||||
output={"cancelar_vas_avulso": {
|
||||
"cancelados": [],
|
||||
"nao_cancelados": [{"msisdn": "11964460192", "servico": "VOD + Canais Abertos", "erro": "Falha no IU", "motivo": "query_vas_failed"}],
|
||||
"nao_encontrados": [],
|
||||
}},
|
||||
)
|
||||
runtime = _Runtime(failed)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
result = asyncio.run(mcp_main._run_cancelamento_com_contestacao({"msisdn": "11964460192", "subject": "VOD + Canais Abertos"}))
|
||||
assert result["status"] == "FAILED"
|
||||
assert result["output"]["cancelar_vas_avulso"]["nao_cancelados"][0]["motivo"] == "query_vas_failed"
|
||||
|
||||
|
||||
def test_cancelamento_aceita_alias_itens_para_contestacao(monkeypatch):
|
||||
cancel = _result(output={"cancelar_vas_avulso": {
|
||||
"success": False,
|
||||
"cancelados": [],
|
||||
"itens_para_contestacao": [{"servico": "Inexistente", "msisdn": "11999999999", "valor": "14,99", "iu_reason": "service_not_found"}],
|
||||
"nao_encontrados": [{"servico": "Inexistente"}],
|
||||
}})
|
||||
contest = _result(output={
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-CANDIDATO"},
|
||||
"abrir_contestacao_cliente": {"success": True, "items_response": [{"status": "INICIADA", "itemName": "Inexistente"}]},
|
||||
})
|
||||
runtime = _Runtime(cancel, contest)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: runtime)
|
||||
result = asyncio.run(mcp_main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999", "subject": "Inexistente", "items": [{"msisdn": "11999999999", "name": "Inexistente", "value": "14,99"}],
|
||||
"complete_invoices_payload": {"billingProfile": {"customer": {"document": "123", "customerId": "C1"}}, "paymentItems": [{"invoiceId": "I1"}]},
|
||||
}))
|
||||
assert [c[0] for c in runtime.calls] == ["cancelamento_vas_avulso", "contestacao_tool"]
|
||||
assert result["contestacao_protocol"] == "PRT-CANDIDATO"
|
||||
|
||||
|
||||
def test_invoice_explanation_service_failure_preserva_fraseologia_sem_auto_finalize():
|
||||
from contas_mcp.servers.contas_mcp_server.main import _result_payload
|
||||
result = {
|
||||
"execution_id": "exec-ie-falha",
|
||||
"workflow_name": "invoice_explanation",
|
||||
"workflow_version": 2,
|
||||
"status": "COMPLETED",
|
||||
"output": {"resposta_falha_servico": {"success": False, "service_failed": True, "mensagem": "texto incorreto"}},
|
||||
"trace": [{"node": "resposta_falha_servico"}],
|
||||
"error": None,
|
||||
}
|
||||
payload = _result_payload(result, workflow_name="invoice_explanation")
|
||||
assert payload["success"] is False
|
||||
assert payload["service_failed"] is True
|
||||
assert payload["auto_finalize_on_failure"] is False
|
||||
assert payload["mensagem"] == "Para continuar com a sua solicitação, aguarde um instante."
|
||||
48
tests/migration/test_business_events_and_guardrails.py
Normal file
48
tests/migration/test_business_events_and_guardrails.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
class _Observer:
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
async def emit(self, code, payload=None, **kwargs):
|
||||
self.events.append((code, payload, kwargs))
|
||||
|
||||
|
||||
class _Runtime(AgentRuntimeMixin):
|
||||
name = "test_agent"
|
||||
|
||||
def __init__(self):
|
||||
self.observer = _Observer()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_business_events_are_published_recursively_once():
|
||||
runtime = _Runtime()
|
||||
state = {"session_id": "s1", "intent": "x", "route": "test_agent"}
|
||||
result = {
|
||||
"ok": True,
|
||||
"cached": False,
|
||||
"result": {
|
||||
"nodes": {
|
||||
"a": {"business_events": ["RCT.001", {"code": "CVN.002", "payload": {"x": 1}}]},
|
||||
"b": {"business_events": ["RCT.001"]},
|
||||
}
|
||||
},
|
||||
}
|
||||
await runtime._publish_business_events(result, state)
|
||||
codes = [event[0] for event in runtime.observer.events]
|
||||
assert codes == ["RCT.001", "CVN.002"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_tool_result_does_not_republish_business_events():
|
||||
runtime = _Runtime()
|
||||
await runtime._publish_business_events(
|
||||
{"cached": True, "business_events": ["RCT.001"]}, {"session_id": "s1"}
|
||||
)
|
||||
assert runtime.observer.events == []
|
||||
273
tests/migration/test_cancel_backend_wrapper_parity.py
Normal file
273
tests/migration/test_cancel_backend_wrapper_parity.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _result(name: str, status: str, output: dict, *, error: str | None = None, error_details=None):
|
||||
return {
|
||||
"execution_id": f"exec-{name}",
|
||||
"workflow_name": name,
|
||||
"workflow_version": 1,
|
||||
"status": status,
|
||||
"output": output,
|
||||
"state": {},
|
||||
"trace": [],
|
||||
"error": error,
|
||||
"error_details": error_details,
|
||||
}
|
||||
|
||||
|
||||
class Service:
|
||||
def consultar_faturas(self, *, msisdn: str):
|
||||
return {
|
||||
"billingProfile": {"customer": {"document": "12345678901", "customerId": "344575482"}},
|
||||
"paymentItems": [{"invoiceId": "5536806781", "dueDate": "2025-08-11"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_item_ja_contestado_preserva_sucesso_do_cancelamento(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.calls=[]
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name,payload,execution_id))
|
||||
if name == "cancelamento_vas_avulso":
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
"contestation_candidates": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
"protocolos_por_linha": [{"msisdn": payload["msisdn"], "protocolo_id": "PRT-CANCEL-1"}],
|
||||
}})
|
||||
return _result(name, "FAILED", {"registrar_protocolo": {"protocolo_id": "PRT-102"}},
|
||||
error="Itens já contestados ou conflito de itens da fatura",
|
||||
error_details={"status_code": 409, "body": {"provider": {"errorCode": "ITEM_JA_CONTESTADO"}}})
|
||||
|
||||
runtime=Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn":"5511999999999","subject":"TIM Fashion Mensal","valor":"14,99"})
|
||||
assert result["success"] is True
|
||||
assert result["erro_sistemico"] is False
|
||||
assert result["auto_finalize_on_failure"] is False
|
||||
assert result["protocol_closed"] is False
|
||||
assert result["contestacao_protocol"] == "PRT-102"
|
||||
assert result["cancelamento_vas_protocol"] == "PRT-CANCEL-1"
|
||||
assert result["protocols_for_response"] == ["PRT-CANCEL-1", "PRT-102"]
|
||||
assert "já contestados" in result["mensagem"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_falha_tecnica_na_contestacao_recomenda_auto_finalizacao(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
if name == "cancelamento_vas_avulso":
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
"contestation_candidates": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
}})
|
||||
return _result(name, "FAILED", {}, error="timeout no provider", error_details={"status_code": 504})
|
||||
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: Runtime())
|
||||
monkeypatch.setattr(main, "service", Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn":"5511999999999","subject":"TIM Fashion Mensal","valor":"14,99"})
|
||||
assert result["success"] is False
|
||||
assert result["erro_sistemico"] is True
|
||||
assert result["auto_finalize_on_failure"] is True
|
||||
assert result["mensagem"] == "timeout no provider"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_sem_contestacao_expoe_protocolo_de_cancelamento(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
"contestation_candidates": [],
|
||||
"protocolos_por_linha": [{"msisdn": payload["msisdn"], "protocolo_id": "PRT-CANCEL-1"}],
|
||||
}})
|
||||
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: Runtime())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn":"5511999999999","subject":"TIM Fashion Mensal"})
|
||||
assert result["cancelamento_vas_protocol"] == "PRT-CANCEL-1"
|
||||
assert result["protocols_for_response"] == ["PRT-CANCEL-1"]
|
||||
assert result["requires_protocol_in_response"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_familia_contesta_titular_e_dependente_em_uma_unica_chamada(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.calls=[]
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name,payload,execution_id))
|
||||
if name == "cancelamento_vas_avulso":
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [
|
||||
{"success": True, "subject": "TIM Fashion Mensal", "msisdn": "5511888880000"},
|
||||
{"success": True, "subject": "TIM Games", "msisdn": "5511999999999"},
|
||||
],
|
||||
"contestation_candidates": [
|
||||
{"success": True, "subject": "TIM Fashion Mensal", "msisdn": "5511888880000"},
|
||||
{"success": True, "subject": "TIM Games", "msisdn": "5511999999999"},
|
||||
],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {"registrar_protocolo":{"protocolo_id":"PRT-FAM"}, "abrir_contestacao_cliente":{"success":True}})
|
||||
|
||||
runtime=Runtime()
|
||||
monkeypatch.setattr(main,"get_workflow_runtime",lambda:runtime)
|
||||
monkeypatch.setattr(main,"service",Service())
|
||||
await main._run_cancelamento_com_contestacao({
|
||||
"msisdn":"5511888880000",
|
||||
"items":[
|
||||
{"msisdn":"5511888880000","name":"TIM Fashion Mensal","value":"14,99"},
|
||||
{"msisdn":"5511999999999","name":"TIM Games","value":"9,90"},
|
||||
],
|
||||
})
|
||||
assert [x[0] for x in runtime.calls] == ["cancelamento_vas_avulso","contestacao_tool"]
|
||||
contest=runtime.calls[1][1]
|
||||
assert contest["msisdn"] == "5511888880000"
|
||||
assert contest["item_msisdn"] == "5511888880000"
|
||||
assert contest["dependent_invoice_item"] is True
|
||||
assert {x["itemName"] for x in contest["items"]} == {"TIM Fashion Mensal","TIM Games"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lote_trinta_contesta_somente_unico_candidato(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.calls=[]
|
||||
async def arun(self,name,payload,execution_id=None):
|
||||
self.calls.append((name,payload,execution_id))
|
||||
if name == "cancelamento_vas_avulso":
|
||||
failures=[{"success":False,"subject":f"VAS não encontrado {i}","msisdn":payload["msisdn"],"reason":"service_not_found","error":"não encontrado"} for i in range(1,30)]
|
||||
ok={"success":True,"subject":"TIM Fashion Mensal","msisdn":payload["msisdn"]}
|
||||
return _result(name,"COMPLETED",{"cancelar_vas_avulso":{"results":[ok,*failures],"contestation_candidates":[ok]}})
|
||||
return _result(name,"COMPLETED",{"registrar_protocolo":{"protocolo_id":"PRT-PARCIAL"},"abrir_contestacao_cliente":{"success":True}})
|
||||
|
||||
runtime=Runtime()
|
||||
monkeypatch.setattr(main,"get_workflow_runtime",lambda:runtime)
|
||||
monkeypatch.setattr(main,"service",Service())
|
||||
items=[{"msisdn":"5511999999999","name":"TIM Fashion Mensal","value":"14,99"}, *[
|
||||
{"msisdn":"5511999999999","name":f"VAS não encontrado {i}","value":"22,00"} for i in range(1,30)
|
||||
]]
|
||||
result=await main._run_cancelamento_com_contestacao({"msisdn":"5511999999999","items":items})
|
||||
assert len(runtime.calls)==2
|
||||
assert [x["itemName"] for x in runtime.calls[1][1]["items"]] == ["TIM Fashion Mensal"]
|
||||
assert result["success"] is True
|
||||
assert result["contestacao_protocol"] == "PRT-PARCIAL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_itens_ja_contestados_preserva_mensagem_deterministica(monkeypatch):
|
||||
from contas_mcp.servers.contas_mcp_server import main
|
||||
|
||||
cancel = _result("cancelamento_vas_avulso", "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "msisdn": "11999999999", "subject": "TIM Fashion Mensal"}],
|
||||
"contestation_candidates": [{"success": True, "msisdn": "11999999999", "subject": "TIM Fashion Mensal"}],
|
||||
}})
|
||||
contest = _result("contestacao_tool", "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-100"},
|
||||
"abrir_contestacao_cliente": {
|
||||
"success": True,
|
||||
"itens_ja_contestados": ["TIM Fashion Mensal"],
|
||||
"items_response": [],
|
||||
"contested_invoice_amount_open": "0",
|
||||
"contested_invoice_amount": "0",
|
||||
},
|
||||
})
|
||||
class Runtime:
|
||||
def __init__(self): self.i=0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
return cancel if self.i == 1 else contest
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", Service())
|
||||
|
||||
result = await main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999",
|
||||
"subject": "TIM Fashion Mensal",
|
||||
"valor": 14.99,
|
||||
"items": [{"msisdn": "11999999999", "name": "TIM Fashion Mensal", "value": 14.99}],
|
||||
})
|
||||
assert result["success"] is True
|
||||
assert result["mensagem"] == (
|
||||
"Identifiquei que o item TIM Fashion Mensal já havia sido contestado anteriormente e, "
|
||||
"por esse motivo, não é possível registrar uma nova contestação para ele. "
|
||||
"Seu número de protocolo é PRT-100."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_contestacao_zerada_usa_total_cancelado_na_mensagem(monkeypatch):
|
||||
from contas_mcp.servers.contas_mcp_server import main
|
||||
|
||||
cancel = _result("cancelamento_vas_avulso", "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "msisdn": "11999999999", "subject": "TIM Fashion Mensal"}],
|
||||
"contestation_candidates": [{"success": True, "msisdn": "11999999999", "subject": "TIM Fashion Mensal"}],
|
||||
}})
|
||||
contest = _result("contestacao_tool", "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-100"},
|
||||
"abrir_contestacao_cliente": {
|
||||
"success": True,
|
||||
"items_response": [{"status": "CRIAR", "itemName": "TIM Fashion Mensal"}],
|
||||
"contested_invoice_amount_open": "0",
|
||||
"contested_invoice_amount": "0",
|
||||
},
|
||||
})
|
||||
class Runtime:
|
||||
def __init__(self): self.i=0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
return cancel if self.i == 1 else contest
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", Service())
|
||||
|
||||
result = await main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999",
|
||||
"subject": "TIM Fashion Mensal",
|
||||
"valor": 14.99,
|
||||
"items": [{"msisdn": "11999999999", "name": "TIM Fashion Mensal", "value": 14.99}],
|
||||
})
|
||||
assert "R$ 14,99" in result["mensagem"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrapper_next_subject_nao_afeta_mensagem_deterministica(monkeypatch):
|
||||
from contas_mcp.servers.contas_mcp_server import main
|
||||
|
||||
cancel = _result("cancelamento_vas_avulso", "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "msisdn": "11999999999", "subject": "TIM Fashion Mensal"}],
|
||||
"contestation_candidates": [{"success": True, "msisdn": "11999999999", "subject": "TIM Fashion Mensal"}],
|
||||
}})
|
||||
contest = _result("contestacao_tool", "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-100"},
|
||||
"abrir_contestacao_cliente": {
|
||||
"success": True,
|
||||
"items_response": [{"status": "CRIAR", "itemName": "TIM Fashion Mensal"}],
|
||||
"contested_invoice_amount_open": "14,99",
|
||||
},
|
||||
})
|
||||
class Runtime:
|
||||
def __init__(self): self.i=0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
return cancel if self.i == 1 else contest
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", Service())
|
||||
|
||||
result = await main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11999999999",
|
||||
"subject": "TIM Fashion Mensal",
|
||||
"valor": 14.99,
|
||||
"next_subject": "contestação de fatura",
|
||||
"items": [{"msisdn": "11999999999", "name": "TIM Fashion Mensal", "value": 14.99}],
|
||||
})
|
||||
assert "contestação de fatura" not in result["mensagem"]
|
||||
602
tests/migration/test_cancel_composite_workflow.py
Normal file
602
tests/migration/test_cancel_composite_workflow.py
Normal file
@@ -0,0 +1,602 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name, payload, execution_id))
|
||||
if name == "cancelamento_vas_avulso":
|
||||
return {
|
||||
"execution_id": "exec-cancel",
|
||||
"workflow_name": name,
|
||||
"workflow_version": 1,
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"cancelar_vas_avulso": {
|
||||
"success": True,
|
||||
"results": [
|
||||
{
|
||||
"success": True,
|
||||
"msisdn": "5511999999999",
|
||||
"subject": "TIM Fashion Mensal",
|
||||
}
|
||||
],
|
||||
"contestation_candidates": [
|
||||
{
|
||||
"success": True,
|
||||
"msisdn": "5511999999999",
|
||||
"subject": "TIM Fashion Mensal",
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"state": {},
|
||||
"trace": [],
|
||||
}
|
||||
assert name == "contestacao_tool"
|
||||
return {
|
||||
"execution_id": "exec-cont",
|
||||
"workflow_name": name,
|
||||
"workflow_version": 2,
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-100"},
|
||||
"abrir_contestacao_cliente": {
|
||||
"success": True,
|
||||
"items_response": [
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "TIM Fashion Mensal",
|
||||
"status": "INICIADA",
|
||||
}
|
||||
],
|
||||
"contested_invoice_amount_open": "14,99",
|
||||
},
|
||||
},
|
||||
"state": {},
|
||||
"trace": [],
|
||||
}
|
||||
|
||||
|
||||
class FakeService:
|
||||
def consultar_faturas(self, *, msisdn: str):
|
||||
assert msisdn == "5511999999999"
|
||||
return {
|
||||
"billingProfile": {
|
||||
"customer": {
|
||||
"document": "12345678901",
|
||||
"customerId": "344575482",
|
||||
}
|
||||
},
|
||||
"paymentItems": [
|
||||
{
|
||||
"invoiceId": "5536806781",
|
||||
"dueDate": "2025-08-11",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_encadeia_contestacao_no_workflow_runtime(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
runtime = FakeRuntime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", FakeService())
|
||||
|
||||
result = await main._run_cancelamento_com_contestacao(
|
||||
{
|
||||
"msisdn": "5511999999999",
|
||||
"subject": "TIM Fashion Mensal",
|
||||
"items": [
|
||||
{
|
||||
"msisdn": "5511999999999",
|
||||
"name": "TIM Fashion Mensal",
|
||||
"value": "14,99",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert [call[0] for call in runtime.calls] == [
|
||||
"cancelamento_vas_avulso",
|
||||
"contestacao_tool",
|
||||
]
|
||||
contest_payload = runtime.calls[1][1]
|
||||
assert contest_payload["social_sec_no"] == "12345678901"
|
||||
assert contest_payload["customer_id"] == "344575482"
|
||||
assert contest_payload["current_invoice_number"] == "5536806781"
|
||||
assert contest_payload["items"][0]["itemName"] == "TIM Fashion Mensal"
|
||||
assert contest_payload["items"][0]["claimedAmount"] == "14,99"
|
||||
assert result["contestacao_protocol"] == "PRT-100"
|
||||
assert result["protocols_for_response"] == ["PRT-100"]
|
||||
assert result["requires_protocol_in_response"] is True
|
||||
assert result["metadata"]["composite_workflows"] == [
|
||||
"cancelamento_vas_avulso",
|
||||
"contestacao_tool",
|
||||
]
|
||||
assert result["mensagem"].endswith("Seu número de protocolo é PRT-100.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_sem_candidato_nao_abre_contestacao(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class NoMatchRuntime(FakeRuntime):
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name, payload, execution_id))
|
||||
return {
|
||||
"execution_id": "exec-cancel",
|
||||
"workflow_name": name,
|
||||
"workflow_version": 1,
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"cancelar_vas_avulso": {
|
||||
"success": False,
|
||||
"results": [
|
||||
{
|
||||
"success": False,
|
||||
"msisdn": payload["items"][0]["msisdn"],
|
||||
"subject": payload["items"][0]["name"],
|
||||
"error": "Serviço não encontrado entre os VAS ativos",
|
||||
}
|
||||
],
|
||||
"contestation_candidates": [],
|
||||
}
|
||||
},
|
||||
"state": {},
|
||||
"trace": [],
|
||||
}
|
||||
|
||||
runtime = NoMatchRuntime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
result = await main._run_cancelamento_com_contestacao(
|
||||
{"msisdn": "5511999999999", "subject": "Inexistente"}
|
||||
)
|
||||
assert [call[0] for call in runtime.calls] == ["cancelamento_vas_avulso"]
|
||||
assert result["metadata"]["composite_workflows"] == ["cancelamento_vas_avulso"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_cancelamento_lote_aproveita_um_sucesso_entre_falhas():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Service:
|
||||
def cancelar_vas_avulso(self, *, msisdn, subject, protocol=""):
|
||||
if subject == "OK":
|
||||
return {"success": True, "service": {"name": subject}}
|
||||
return {"success": False, "subject": subject, "reason": "service_not_found", "error": "não encontrado"}
|
||||
|
||||
class Store:
|
||||
@staticmethod
|
||||
def canonical_key(*parts): return "|".join(map(str, parts))
|
||||
async def get(self, key): return None
|
||||
async def set(self, key, value, ttl_seconds=None): return None
|
||||
|
||||
reg = build_contas_workflow_actions(Service(), idempotency_store=Store()) # type: ignore[arg-type]
|
||||
action = reg.get("cancelamento_vas_avulso_batch")
|
||||
result = await action(
|
||||
{"items": [
|
||||
{"msisdn": "119", "name": "FALHA1"},
|
||||
{"msisdn": "119", "name": "OK"},
|
||||
{"msisdn": "119", "name": "FALHA2"},
|
||||
]},
|
||||
{"input": {}, "execution_id": "e1"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert len(result["cancelados"]) == 1
|
||||
assert len(result["nao_encontrados"]) == 2
|
||||
assert len(result["itens_para_contestacao"]) == 1
|
||||
assert result["itens_para_contestacao"][0]["subject"] == "OK"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plano_familia_mantem_titular_na_contestacao_e_dependente_no_cancelamento(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class FamilyRuntime(FakeRuntime):
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name, payload, execution_id))
|
||||
if name == "cancelamento_vas_avulso":
|
||||
return {
|
||||
"execution_id": "exec-cancel",
|
||||
"workflow_name": name,
|
||||
"workflow_version": 1,
|
||||
"status": "COMPLETED",
|
||||
"output": {"cancelar_vas_avulso": {
|
||||
"success": True,
|
||||
"results": [{"success": True, "msisdn": "5511999999999", "subject": "TIM Fashion Mensal"}],
|
||||
"contestation_candidates": [{"success": True, "msisdn": "5511999999999", "subject": "TIM Fashion Mensal"}],
|
||||
}},
|
||||
"state": {}, "trace": [],
|
||||
}
|
||||
assert name == "contestacao_tool"
|
||||
return {
|
||||
"execution_id": "exec-cont", "workflow_name": name, "workflow_version": 2,
|
||||
"status": "COMPLETED",
|
||||
"output": {"registrar_protocolo": {"protocolo_id": "PRT-FAM"}, "abrir_contestacao_cliente": {"success": True}},
|
||||
"state": {}, "trace": [],
|
||||
}
|
||||
|
||||
runtime = FamilyRuntime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", FakeService())
|
||||
prefetch = {"billingProfile": {"customer": {"document": "12345678901", "customerId": "344575482"}}, "paymentItems": [{"invoiceId": "5536806781"}]}
|
||||
result = await main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "5511888880000", # titular
|
||||
"items": [{"msisdn": "5511999999999", "name": "TIM Fashion Mensal", "value": "14,99"}],
|
||||
"complete_invoices_payload": prefetch,
|
||||
})
|
||||
assert result["contestacao_protocol"] == "PRT-FAM"
|
||||
cancel_payload = runtime.calls[0][1]
|
||||
assert cancel_payload["msisdn"] == "5511888880000"
|
||||
assert cancel_payload["items"][0]["msisdn"] == "5511999999999"
|
||||
contest_payload = runtime.calls[1][1]
|
||||
assert contest_payload["msisdn"] == "5511888880000"
|
||||
assert contest_payload["item_msisdn"] == "5511888880000"
|
||||
assert contest_payload["dependent_invoice_item"] is True
|
||||
assert contest_payload["complete_invoices_payload"] == prefetch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_detail_corrige_linha_do_item_antes_do_cancelamento(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime(FakeRuntime):
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name, payload, execution_id))
|
||||
return {
|
||||
"execution_id": "exec-cancel", "workflow_name": name, "workflow_version": 1,
|
||||
"status": "COMPLETED",
|
||||
"output": {"cancelar_vas_avulso": {"success": True, "results": [], "contestation_candidates": []}},
|
||||
"state": {}, "trace": [],
|
||||
}
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
await main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "11964460192",
|
||||
"items": [
|
||||
{"msisdn": "11964460192", "name": "Aluguel de Filme 2", "value": 19.9},
|
||||
{"msisdn": "11964460192", "name": "Aluguel de Filme 3", "value": 19.9},
|
||||
],
|
||||
"invoice_detail": {
|
||||
"11964460192": {"SVA Detalhe Total": [{"desc": "Aluguel de Filme 2", "value": 19.9, "msisdn": "11964460192"}]},
|
||||
"11964490143": {"SVA Detalhe Total": [{"desc": "Aluguel de Filme 3", "value": 19.9, "msisdn": "11964490143"}]},
|
||||
},
|
||||
})
|
||||
items = runtime.calls[0][1]["items"]
|
||||
assert items[0]["msisdn"] == "11964460192"
|
||||
assert items[1]["msisdn"] == "11964490143"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_propaga_bloqueio_da_contestacao_sem_mascarar_sucesso(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime(FakeRuntime):
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name, payload, execution_id))
|
||||
if name == "cancelamento_vas_avulso":
|
||||
return {
|
||||
"execution_id": "c", "workflow_name": name, "workflow_version": 1, "status": "COMPLETED",
|
||||
"output": {"cancelar_vas_avulso": {"success": True, "results": [{"success": True, "msisdn": payload["msisdn"], "subject": "Disney"}], "contestation_candidates": [{"success": True, "msisdn": payload["msisdn"], "subject": "Disney"}]}},
|
||||
"state": {}, "trace": [],
|
||||
}
|
||||
return {
|
||||
"execution_id": "x", "workflow_name": name, "workflow_version": 2, "status": "COMPLETED",
|
||||
"output": {"abrir_contestacao_cliente": {"success": False, "blocked": True, "guardrail_code": "CVAL", "guardrail_reason": "VAS estratégico não pode ser ajustado"}},
|
||||
"state": {}, "trace": [],
|
||||
}
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", FakeService())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "Disney", "valor": 10})
|
||||
assert result["success"] is False
|
||||
assert result["erro_sistemico"] is True
|
||||
assert result["mensagem"] == "VAS estratégico não pode ser ajustado"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_lote_nao_executa_side_effect_se_protocolo_por_linha_falhar():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Client:
|
||||
def abrir_protocolo(self, payload):
|
||||
return {}
|
||||
|
||||
class Service:
|
||||
client = Client()
|
||||
def __init__(self): self.cancel_calls = 0
|
||||
def cancelar_vas_avulso(self, *, msisdn, subject, protocol=""):
|
||||
self.cancel_calls += 1
|
||||
return {"success": True}
|
||||
|
||||
class Store:
|
||||
@staticmethod
|
||||
def canonical_key(*parts): return "|".join(map(str, parts))
|
||||
async def get(self, key): return None
|
||||
async def set(self, key, value, ttl_seconds=None): return None
|
||||
|
||||
service = Service()
|
||||
reg = build_contas_workflow_actions(service, idempotency_store=Store()) # type: ignore[arg-type]
|
||||
result = await reg.get("cancelamento_vas_avulso_batch")(
|
||||
{"items": [{"msisdn": "119", "name": "VAS A"}], "message_id": "m1"},
|
||||
{"input": {}, "execution_id": "e1"},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["nao_cancelados"][0]["reason"] == "protocol_registration_failed"
|
||||
assert service.cancel_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_lote_limita_concorrencia_a_cinco(monkeypatch):
|
||||
import threading
|
||||
import time
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
monkeypatch.setenv("TIM_CANCELAMENTO_BATCH_CONCURRENCY", "5")
|
||||
|
||||
class Service:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.active = 0
|
||||
self.max_active = 0
|
||||
def cancelar_vas_avulso(self, *, msisdn, subject, protocol=""):
|
||||
with self.lock:
|
||||
self.active += 1
|
||||
self.max_active = max(self.max_active, self.active)
|
||||
time.sleep(0.03)
|
||||
with self.lock:
|
||||
self.active -= 1
|
||||
return {"success": True, "service": {"name": subject}}
|
||||
|
||||
class Store:
|
||||
@staticmethod
|
||||
def canonical_key(*parts): return "|".join(map(str, parts))
|
||||
async def get(self, key): return None
|
||||
async def set(self, key, value, ttl_seconds=None): return None
|
||||
|
||||
service = Service()
|
||||
reg = build_contas_workflow_actions(service, idempotency_store=Store()) # type: ignore[arg-type]
|
||||
items = [{"msisdn": "119", "name": f"VAS {i}", "protocol": f"P{i}"} for i in range(10)]
|
||||
result = await reg.get("cancelamento_vas_avulso_batch")(
|
||||
{"items": items}, {"input": {}, "execution_id": "e1"}
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert len(result["cancelados"]) == 10
|
||||
assert service.max_active == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falha_de_bloqueio_permanece_candidata_a_contestacao():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Client:
|
||||
def consultar_vas(self, msisdn):
|
||||
return {"products": [{"name": "TIM Fashion Mensal", "canCancel": True, "appId": "1", "cspId": "740"}]}
|
||||
def historico_vas(self, msisdn): return {"products": []}
|
||||
def bloquear_vas(self, msisdn, service): raise RuntimeError("Timeout no bloqueio")
|
||||
def cancelar_vas(self, msisdn, service, protocol=""):
|
||||
raise AssertionError("cancelamento não deve ocorrer após falha de bloqueio")
|
||||
|
||||
class Service:
|
||||
client = Client()
|
||||
def cancelar_vas_avulso(self, *, msisdn, subject, protocol=""):
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
return ContasDomainService(self.client).cancelar_vas_avulso(msisdn=msisdn, subject=subject, protocol=protocol)
|
||||
|
||||
class Store:
|
||||
@staticmethod
|
||||
def canonical_key(*parts): return "|".join(map(str, parts))
|
||||
async def get(self, key): return None
|
||||
async def set(self, key, value, ttl_seconds=None): return None
|
||||
|
||||
# Pass a pre-existing protocol so this unit test isolates the VAS side effect.
|
||||
reg = build_contas_workflow_actions(Service(), idempotency_store=Store()) # type: ignore[arg-type]
|
||||
result = await reg.get("cancelamento_vas_avulso_batch")(
|
||||
{"items": [{"msisdn": "5511999999999", "name": "TIM Fashion Mensal", "protocol": "PRT-1"}]},
|
||||
{"input": {}, "execution_id": "e-block"},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["results"][0]["reason"] == "block_vas_failed"
|
||||
assert result["results"][0]["eligible_for_contestation"] is True
|
||||
assert len(result["itens_para_contestacao"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resposta_composta_preserva_protocolos_cancelamento_e_contestacao(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime(FakeRuntime):
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append((name, payload, execution_id))
|
||||
if name == "cancelamento_vas_avulso":
|
||||
return {
|
||||
"execution_id": "cancel", "workflow_name": name, "workflow_version": 1, "status": "COMPLETED",
|
||||
"output": {"cancelar_vas_avulso": {
|
||||
"success": True,
|
||||
"results": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal"}],
|
||||
"contestation_candidates": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal"}],
|
||||
"protocolos_por_linha": [{"msisdn": "5511999999999", "protocolo_id": "PRT-CANCEL-1"}],
|
||||
}}, "state": {}, "trace": [],
|
||||
}
|
||||
return {
|
||||
"execution_id": "cont", "workflow_name": name, "workflow_version": 2, "status": "COMPLETED",
|
||||
"output": {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-CONTEST-1", "protocol_closed": True},
|
||||
"abrir_contestacao_cliente": {"success": True, "items_response": []},
|
||||
}, "state": {}, "trace": [],
|
||||
}
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", FakeService())
|
||||
result = await main._run_cancelamento_com_contestacao({
|
||||
"msisdn": "5511999999999", "subject": "Tamboro Mensal", "valor": "14,99",
|
||||
})
|
||||
assert result["success"] is True
|
||||
assert result["recomenda_finalizacao"] is True
|
||||
assert result["status_finalizacao_sugerido"] == "resolvido"
|
||||
assert result["protocols_for_response"] == ["PRT-CANCEL-1", "PRT-CONTEST-1"]
|
||||
assert result["requires_protocol_in_response"] is True
|
||||
assert result["protocol_closed"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_dependente_resolve_cpf_da_linha_antes_do_protocolo(monkeypatch):
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
recorded = {}
|
||||
class Client:
|
||||
def line_info(self, msisdn):
|
||||
assert msisdn == "5511999999999"
|
||||
return {"social_sec_no": "CPF-DEPENDENTE"}
|
||||
def abrir_protocolo(self, payload):
|
||||
recorded.update(payload)
|
||||
return {"protocol": "PRT-DEP"}
|
||||
|
||||
class Service:
|
||||
client = Client()
|
||||
def cancelar_vas_avulso(self, *, msisdn, subject, protocol=""):
|
||||
return {"success": True, "service": {"name": subject}}
|
||||
|
||||
class Store:
|
||||
@staticmethod
|
||||
def canonical_key(*parts): return "|".join(map(str, parts))
|
||||
async def get(self, key): return None
|
||||
async def set(self, key, value, ttl_seconds=None): return None
|
||||
|
||||
reg = build_contas_workflow_actions(Service(), idempotency_store=Store()) # type: ignore[arg-type]
|
||||
result = await reg.get("cancelamento_vas_avulso_batch")(
|
||||
{
|
||||
"msisdn": "5511888880000",
|
||||
"social_sec_no": "CPF-TITULAR",
|
||||
"items": [{"msisdn": "5511999999999", "name": "TIM Fashion Mensal"}],
|
||||
},
|
||||
{"input": {}, "execution_id": "e-family"},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert recorded["socialSecNo"] == "CPF-DEPENDENTE"
|
||||
assert recorded["msisdn"] == "5511999999999"
|
||||
|
||||
|
||||
def test_result_payload_invoice_explanation_recomenda_finalizacao_por_last_node():
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
accepted = main._result_payload({
|
||||
"execution_id": "ie-1",
|
||||
"workflow_name": "invoice_explanation",
|
||||
"workflow_version": 2,
|
||||
"status": "COMPLETED",
|
||||
"output": {},
|
||||
"state": {},
|
||||
"trace": [{"node": "registrar_protocolo_aceite"}],
|
||||
"error_details": {},
|
||||
}, workflow_name="invoice_explanation")
|
||||
assert accepted["recomenda_finalizacao"] is True
|
||||
assert accepted["status_finalizacao_sugerido"] == "resolvido"
|
||||
|
||||
refused = main._result_payload({
|
||||
"execution_id": "ie-2",
|
||||
"workflow_name": "invoice_explanation",
|
||||
"workflow_version": 2,
|
||||
"status": "COMPLETED",
|
||||
"output": {},
|
||||
"state": {},
|
||||
"trace": [{"node": "finalizar_nao_resolvido"}],
|
||||
"error_details": {},
|
||||
}, workflow_name="invoice_explanation")
|
||||
assert refused["recomenda_finalizacao"] is True
|
||||
assert refused["status_finalizacao_sugerido"] == "nao_resolvido"
|
||||
|
||||
vas_branch = main._result_payload({
|
||||
"execution_id": "ie-3",
|
||||
"workflow_name": "invoice_explanation",
|
||||
"workflow_version": 2,
|
||||
"status": "COMPLETED",
|
||||
"output": {},
|
||||
"state": {},
|
||||
"trace": [{"node": "checar_vas_variacao"}],
|
||||
"error_details": {},
|
||||
}, workflow_name="invoice_explanation")
|
||||
assert "recomenda_finalizacao" not in vas_branch
|
||||
|
||||
|
||||
def test_result_payload_pro_rata_recomenda_finalizacao_somente_nos_ramos_finais():
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
for node in ("registrar_aceitou", "registrar_nao_controle"):
|
||||
result = main._result_payload({
|
||||
"execution_id": f"pr-{node}",
|
||||
"workflow_name": "pro_rata",
|
||||
"workflow_version": 3,
|
||||
"status": "COMPLETED",
|
||||
"output": {},
|
||||
"state": {},
|
||||
"trace": [{"node": node}],
|
||||
"error_details": {},
|
||||
}, workflow_name="pro_rata")
|
||||
assert result["recomenda_finalizacao"] is True
|
||||
assert result["status_finalizacao_sugerido"] == "resolvido"
|
||||
|
||||
unresolved = main._result_payload({
|
||||
"execution_id": "pr-no",
|
||||
"workflow_name": "pro_rata",
|
||||
"workflow_version": 3,
|
||||
"status": "COMPLETED",
|
||||
"output": {},
|
||||
"state": {},
|
||||
"trace": [{"node": "devolver_orquestrador"}],
|
||||
"error_details": {},
|
||||
}, workflow_name="pro_rata")
|
||||
assert "recomenda_finalizacao" not in unresolved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_redireciona_item_estrategico_para_workflow_correto(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
from types import SimpleNamespace
|
||||
|
||||
resolved = SimpleNamespace(
|
||||
canonical_name="Paramount+ Mensal",
|
||||
msisdn="5511999999999",
|
||||
charge_date=None,
|
||||
value=12.34,
|
||||
tool_category="vas_estrategico",
|
||||
item_type="estrategico",
|
||||
)
|
||||
outcome = SimpleNamespace(ambiguous=[], resolved=[resolved], out_of_scope=[])
|
||||
monkeypatch.setattr(main.invoice_resolver, "resolve", lambda *_args, **_kwargs: outcome)
|
||||
monkeypatch.setattr(main.service.client, "billing_analysis", lambda _msisdn: {"currentInvoice": {}})
|
||||
monkeypatch.setattr(main, "_enrich_invoice_context", lambda *args, **kwargs: __import__('asyncio').sleep(0))
|
||||
|
||||
class Runtime:
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
assert name == "vas_estrategico"
|
||||
assert payload["items"] == [{
|
||||
"type": "estrategico",
|
||||
"msisdn": "5511999999999",
|
||||
"name": "Paramount+ Mensal",
|
||||
}]
|
||||
return {
|
||||
"execution_id": "exec-vas",
|
||||
"workflow_name": name,
|
||||
"workflow_version": 3,
|
||||
"status": "COMPLETED",
|
||||
"output": {},
|
||||
"state": {},
|
||||
"trace": [{"node": "registrar_atendimento"}],
|
||||
"error_details": {},
|
||||
}
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: Runtime())
|
||||
|
||||
result = await main._invoke("cancelar_vas_avulso", {
|
||||
"msisdn": "5511999999999",
|
||||
"subject": "Paramount+ Mensal",
|
||||
"billing_analysis": {"currentInvoice": {}},
|
||||
"complete_invoices_payload": {},
|
||||
})
|
||||
assert result["metadata"]["domain_redirect_from"] == "cancelar_vas_avulso"
|
||||
assert result["metadata"]["domain_redirect_to"] == "tratar_vas_estrategico"
|
||||
346
tests/migration/test_cancel_wrapper_remaining_parity.py
Normal file
346
tests/migration/test_cancel_wrapper_remaining_parity.py
Normal file
@@ -0,0 +1,346 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _result(name: str, status: str, output: dict, *, error: str | None = None):
|
||||
return {
|
||||
"execution_id": f"exec-{name}",
|
||||
"workflow_name": name,
|
||||
"workflow_version": 1,
|
||||
"status": status,
|
||||
"output": output,
|
||||
"state": {},
|
||||
"trace": [],
|
||||
"error": error,
|
||||
"error_details": None,
|
||||
}
|
||||
|
||||
|
||||
class _Service:
|
||||
def consultar_faturas(self, *, msisdn: str):
|
||||
return {
|
||||
"billingProfile": {"customer": {"document": "12345678901", "customerId": "344575482"}},
|
||||
"paymentItems": [{"invoiceId": "5536806781", "dueDate": "2025-08-11"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_implicito_por_cancelados_sem_flag_success(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i = 0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
if self.i == 1:
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"cancelados": [{"msisdn": payload["msisdn"], "servico": "Tamboro Mensal", "valor": "14,99"}],
|
||||
"itens_para_contestacao": [{"msisdn": payload["msisdn"], "subject": "Tamboro Mensal"}],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-D2EFF9B40B", "protocol_closed": True},
|
||||
"abrir_contestacao_cliente": {"success": True, "contested_items": [{"itemName": "Tamboro Mensal", "status": "INICIADA"}]},
|
||||
})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", _Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "Tamboro Mensal", "valor": "14,99"})
|
||||
assert result["success"] is True
|
||||
assert result["protocol_closed"] is True
|
||||
assert result["contestacao_protocol"] == "PRT-D2EFF9B40B"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lista_explicita_vazia_de_candidatos_nao_faz_fallback_para_cancelados(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.calls = []
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append(name)
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"cancelados": [{"msisdn": payload["msisdn"], "servico": "TIM Fashion Mensal"}],
|
||||
"contestation_candidates": [],
|
||||
}})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "TIM Fashion Mensal", "valor": "14,99"})
|
||||
assert runtime.calls == ["cancelamento_vas_avulso"]
|
||||
assert result["success"] is True
|
||||
assert "contestacao_workflow" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_nao_contesta_valor_nem_chama_contestacao(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.calls = []
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.calls.append(name)
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": False, "subject": "Inexistente", "error": "serviço não encontrado", "reason": "service_not_found"}],
|
||||
"contestation_candidates": [],
|
||||
"nao_encontrados": [{"servico": "Inexistente"}],
|
||||
}})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "Inexistente", "valor": "99,99"})
|
||||
assert runtime.calls == ["cancelamento_vas_avulso"]
|
||||
assert result["success"] is False
|
||||
assert "contestacao_workflow" not in result
|
||||
assert "99,99" not in result["mensagem"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_protocol_closed_da_contestacao_e_preservado(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i = 0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
if self.i == 1:
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
"contestation_candidates": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-200", "protocol_closed": True},
|
||||
"abrir_contestacao_cliente": {"success": True, "contested_items": [{"itemName": "TIM Fashion Mensal", "status": "INICIADA"}], "protocol_closed": True},
|
||||
})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", _Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "TIM Fashion Mensal", "valor": "14,99"})
|
||||
assert result["success"] is True
|
||||
assert result["protocol_closed"] is True
|
||||
assert result["contestacao_protocol"] == "PRT-200"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sms_flag_interno_nao_vaza_no_contrato_externo(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i = 0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
if self.i == 1:
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "subject": "Conteudo Premium", "msisdn": payload["msisdn"]}],
|
||||
"contestation_candidates": [{"success": True, "subject": "Conteudo Premium", "msisdn": payload["msisdn"]}],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-201"},
|
||||
"abrir_contestacao_cliente": {"success": True},
|
||||
"enviar_sms": {"success": True, "sms_sent": True},
|
||||
})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", _Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511988887777", "subject": "Conteudo Premium", "valor": "9,90"})
|
||||
assert result["contestacao_protocol"] == "PRT-201"
|
||||
assert "sms_sent" not in result
|
||||
assert result["sms_not_send_error"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelamento_com_protocolo_sem_item_contestado_continua_resolvido(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i = 0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
if self.i == 1:
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"cancelados": [{"msisdn": payload["msisdn"], "servico": "Tamboro Mensal"}],
|
||||
"itens_para_contestacao": [{"msisdn": payload["msisdn"], "subject": "Tamboro Mensal"}],
|
||||
"protocolos_por_linha": [{"msisdn": payload["msisdn"], "protocolo_id": "PRT-CANCEL-1"}],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-CONTEST-1"},
|
||||
"abrir_contestacao_cliente": {"success": True, "contested_items": [], "not_contested_items": [], "contested_invoice_amount_open": "0"},
|
||||
})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", _Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "Tamboro Mensal", "valor": "14,99"})
|
||||
assert result["success"] is True
|
||||
assert result["recomenda_finalizacao"] is True
|
||||
assert result["status_finalizacao_sugerido"] == "resolvido"
|
||||
assert result["protocols_for_response"] == ["PRT-CANCEL-1", "PRT-CONTEST-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falha_sms_e_propagada_sem_expor_sms_sent(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i = 0
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1
|
||||
if self.i == 1:
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
"contestation_candidates": [{"success": True, "subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"]}],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-101"},
|
||||
"abrir_contestacao_cliente": {"success": True},
|
||||
"enviar_sms": {"success": False, "sms_sent": False, "sms_not_send_error": True},
|
||||
})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", _Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "TIM Fashion Mensal", "valor": "14,99"})
|
||||
assert result["success"] is True
|
||||
assert result["sms_not_send_error"] is True
|
||||
assert "sms_sent" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_candidato_explicito_e_contestado_mesmo_sem_cancelamento_bem_sucedido(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i = 0; self.calls=[]
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1; self.calls.append((name, payload))
|
||||
if self.i == 1:
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": False, "subject": "Inexistente", "reason": "service_not_found", "error": "não encontrado"}],
|
||||
"contestation_candidates": [{"success": False, "subject": "Inexistente", "msisdn": payload["msisdn"], "iu_reason": "service_not_found"}],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-CANDIDATO"},
|
||||
"abrir_contestacao_cliente": {"success": True, "contested_items": [{"itemName": "Inexistente", "status": "INICIADA"}]},
|
||||
})
|
||||
|
||||
runtime = Runtime()
|
||||
monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime)
|
||||
monkeypatch.setattr(main, "service", _Service())
|
||||
result = await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "Inexistente", "valor": "14,99"})
|
||||
assert [x[0] for x in runtime.calls] == ["cancelamento_vas_avulso", "contestacao_tool"]
|
||||
assert runtime.calls[1][1]["items"][0]["itemName"] == "Inexistente"
|
||||
assert runtime.calls[1][1]["items"][0]["claimedAmount"] == "14,99"
|
||||
assert result["contestacao_protocol"] == "PRT-CANDIDATO"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("reason,error", [("block_vas_failed", "Timeout no bloqueio"), ("cancel_vas_failed", "Erro no cancelamento")])
|
||||
async def test_falha_parcial_rt01_ainda_dispara_rt02(monkeypatch, reason, error):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i=0; self.calls=[]
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
self.i += 1; self.calls.append((name,payload))
|
||||
if self.i == 1:
|
||||
return _result(name, "COMPLETED", {"cancelar_vas_avulso": {
|
||||
"results": [{"success": False, "subject": "TIM Fashion Mensal", "reason": reason, "error": error}],
|
||||
"nao_cancelados": [{"servico": "TIM Fashion Mensal", "motivo": reason, "erro": error}],
|
||||
"contestation_candidates": [{"subject": "TIM Fashion Mensal", "msisdn": payload["msisdn"], "motivo": reason, "erro": error}],
|
||||
}})
|
||||
return _result(name, "COMPLETED", {
|
||||
"registrar_protocolo": {"protocolo_id": "PRT-RT02"},
|
||||
"abrir_contestacao_cliente": {"success": True, "contested_items": [{"itemName": "TIM Fashion Mensal", "status": "INICIADA"}]},
|
||||
})
|
||||
|
||||
runtime=Runtime()
|
||||
monkeypatch.setattr(main,"get_workflow_runtime",lambda:runtime)
|
||||
monkeypatch.setattr(main,"service",_Service())
|
||||
result=await main._run_cancelamento_com_contestacao({"msisdn":"5511999999999","subject":"TIM Fashion Mensal","valor":"14,99"})
|
||||
assert [x[0] for x in runtime.calls] == ["cancelamento_vas_avulso","contestacao_tool"]
|
||||
assert result["contestacao_protocol"] == "PRT-RT02"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_holder_msisdn_nao_e_trocado_pela_linha_dependente(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i=0; self.calls=[]
|
||||
async def arun(self,name,payload,execution_id=None):
|
||||
self.i += 1; self.calls.append((name,payload))
|
||||
if self.i == 1:
|
||||
return _result(name,"COMPLETED",{"cancelar_vas_avulso":{
|
||||
"results":[{"success":True,"subject":"TIM Fashion Mensal","msisdn":"5511888880000"}],
|
||||
"contestation_candidates":[{"success":True,"subject":"TIM Fashion Mensal","msisdn":"5511888880000"}],
|
||||
}})
|
||||
return _result(name,"COMPLETED",{"registrar_protocolo":{"protocolo_id":"PRT-FAM"},"abrir_contestacao_cliente":{"success":True}})
|
||||
|
||||
runtime=Runtime()
|
||||
monkeypatch.setattr(main,"get_workflow_runtime",lambda:runtime)
|
||||
monkeypatch.setattr(main,"service",_Service())
|
||||
await main._run_cancelamento_com_contestacao({
|
||||
"msisdn":"5511888880000",
|
||||
"holder_msisdn":"5511999999999",
|
||||
"items":[{"msisdn":"5511888880000","name":"TIM Fashion Mensal","value":"14,99"}],
|
||||
})
|
||||
assert runtime.calls[0][1]["msisdn"] == "5511999999999"
|
||||
assert runtime.calls[1][1]["msisdn"] == "5511999999999"
|
||||
assert runtime.calls[1][1]["dependent_invoice_item"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_do_titular_nao_e_marcado_como_dependente(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i=0; self.calls=[]
|
||||
async def arun(self,name,payload,execution_id=None):
|
||||
self.i += 1; self.calls.append((name,payload))
|
||||
if self.i == 1:
|
||||
return _result(name,"COMPLETED",{"cancelar_vas_avulso":{
|
||||
"results":[{"success":True,"subject":"TIM Games","msisdn":"5511999999999"}],
|
||||
"contestation_candidates":[{"success":True,"subject":"TIM Games","msisdn":"5511999999999"}],
|
||||
}})
|
||||
return _result(name,"COMPLETED",{"registrar_protocolo":{"protocolo_id":"PRT-TIT"},"abrir_contestacao_cliente":{"success":True}})
|
||||
|
||||
runtime=Runtime()
|
||||
monkeypatch.setattr(main,"get_workflow_runtime",lambda:runtime)
|
||||
monkeypatch.setattr(main,"service",_Service())
|
||||
await main._run_cancelamento_com_contestacao({"msisdn":"5511999999999","subject":"TIM Games","valor":"9,90"})
|
||||
assert runtime.calls[1][1]["dependent_invoice_item"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_titular_e_dependentes_permancem_em_uma_unica_contestacao(monkeypatch):
|
||||
import contas_mcp.servers.contas_mcp_server.main as main
|
||||
|
||||
class Runtime:
|
||||
def __init__(self): self.i=0; self.calls=[]
|
||||
async def arun(self,name,payload,execution_id=None):
|
||||
self.i += 1; self.calls.append((name,payload))
|
||||
if self.i == 1:
|
||||
cands=[
|
||||
{"success":True,"subject":"TIM Games","msisdn":"5511999999999"},
|
||||
{"success":True,"subject":"TIM Fashion Mensal","msisdn":"5511888880000"},
|
||||
{"success":True,"subject":"Tamboro Mensal","msisdn":"5511777770000"},
|
||||
]
|
||||
return _result(name,"COMPLETED",{"cancelar_vas_avulso":{"results":cands,"contestation_candidates":cands}})
|
||||
return _result(name,"COMPLETED",{"registrar_protocolo":{"protocolo_id":"PRT-FAM-3"},"abrir_contestacao_cliente":{"success":True}})
|
||||
|
||||
runtime=Runtime()
|
||||
monkeypatch.setattr(main,"get_workflow_runtime",lambda:runtime)
|
||||
monkeypatch.setattr(main,"service",_Service())
|
||||
await main._run_cancelamento_com_contestacao({
|
||||
"msisdn":"5511999999999",
|
||||
"items":[
|
||||
{"msisdn":"5511999999999","name":"TIM Games","value":"9,90"},
|
||||
{"msisdn":"5511888880000","name":"TIM Fashion Mensal","value":"14,99"},
|
||||
{"msisdn":"5511777770000","name":"Tamboro Mensal","value":"8,50"},
|
||||
],
|
||||
})
|
||||
assert [x[0] for x in runtime.calls] == ["cancelamento_vas_avulso","contestacao_tool"]
|
||||
contest=runtime.calls[1][1]
|
||||
assert contest["msisdn"] == "5511999999999"
|
||||
assert {x["itemName"] for x in contest["items"]} == {"TIM Games","TIM Fashion Mensal","Tamboro Mensal"}
|
||||
assert contest["dependent_invoice_item"] is True
|
||||
392
tests/migration/test_contas_domain_mock.py
Normal file
392
tests/migration/test_contas_domain_mock.py
Normal file
@@ -0,0 +1,392 @@
|
||||
from app.domain.contas import ContasDomainService
|
||||
|
||||
|
||||
def test_read_only_integrations_with_fixtures(monkeypatch):
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "true")
|
||||
svc = ContasDomainService()
|
||||
assert svc.consultar_faturas(msisdn="11999999999").get("paymentItems")
|
||||
assert svc.consultar_vas(msisdn="11999999999").get("products")
|
||||
assert svc.consultar_historico_vas(msisdn="11999999999").get("services")
|
||||
|
||||
|
||||
def test_cancel_vas_is_domain_operation_not_conversation_engine(monkeypatch):
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "true")
|
||||
svc = ContasDomainService()
|
||||
out = svc.cancelar_vas_avulso(msisdn="11999999999", subject="TIM Fashion Mensal")
|
||||
assert out["success"] is True
|
||||
assert out["block"]["status"] == 200
|
||||
assert out["cancellation"]["status"] == 200
|
||||
|
||||
|
||||
def test_contestation_mock(monkeypatch):
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "true")
|
||||
svc = ContasDomainService()
|
||||
out = svc.contestar_cobranca(msisdn="11999999999", subject="Tamboro Mensal", valor=14.99, motivo="não reconheço")
|
||||
assert out["success"] is True
|
||||
assert out["protocol"]
|
||||
assert out["contestation"]
|
||||
|
||||
|
||||
def test_cancelamento_usa_historico_como_fallback_quando_ativo_nao_encontra():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.blocked = []
|
||||
self.cancelled = []
|
||||
def consultar_vas(self, msisdn):
|
||||
return {"products": []}
|
||||
def historico_vas(self, msisdn):
|
||||
return {"services": [{"name": "Max mensal fatura", "appId": 14395, "cspId": 825, "canCancel": True}]}
|
||||
def bloquear_vas(self, msisdn, service):
|
||||
self.blocked.append((msisdn, service))
|
||||
return {"ok": True}
|
||||
def cancelar_vas(self, msisdn, service, protocol=""):
|
||||
self.cancelled.append((msisdn, service, protocol))
|
||||
return {"ok": True}
|
||||
|
||||
client = Client()
|
||||
result = ContasDomainService(client=client).cancelar_vas_avulso(
|
||||
msisdn="11999999999", subject="Max mensal fatura", protocol="PRT-1"
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["evidence_source"] == "history"
|
||||
assert client.blocked[0][1]["appId"] == 14395
|
||||
assert client.cancelled[0][2] == "PRT-1"
|
||||
|
||||
|
||||
def test_cancelamento_nao_recancela_servico_inativo_do_historico():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class Client:
|
||||
def consultar_vas(self, msisdn):
|
||||
return {"products": []}
|
||||
def historico_vas(self, msisdn):
|
||||
return {"services": [{"name": "Max mensal fatura", "appId": 14395, "cspId": 825, "canCancel": False}]}
|
||||
def bloquear_vas(self, *args, **kwargs):
|
||||
raise AssertionError("não deve bloquear")
|
||||
def cancelar_vas(self, *args, **kwargs):
|
||||
raise AssertionError("não deve cancelar")
|
||||
|
||||
result = ContasDomainService(client=Client()).cancelar_vas_avulso(
|
||||
msisdn="11999999999", subject="Max mensal fatura"
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result["reason"] == "already_inactive_or_blocked"
|
||||
|
||||
|
||||
def test_finalizacao_normaliza_status_invalido_e_summary():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class Client: pass
|
||||
result = ContasDomainService(client=Client()).finalizar_atendimento(
|
||||
status="qualquer_coisa", summary=""
|
||||
)
|
||||
assert result["status"] == "erro_falha_sistema"
|
||||
assert result["summary"] == "Encerramento realizado pelo agente."
|
||||
|
||||
|
||||
def test_finalizacao_preserva_status_valido_e_prefixa_summary():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class Client: pass
|
||||
result = ContasDomainService(client=Client()).finalizar_atendimento(
|
||||
status="resolvido_outros_assuntos",
|
||||
summary="VAS cancelado e cliente quer falar de plano.",
|
||||
)
|
||||
assert result["status"] == "resolvido_outros_assuntos"
|
||||
assert result["summary"] == (
|
||||
"Encerramento realizado pelo agente. VAS cancelado e cliente quer falar de plano."
|
||||
)
|
||||
|
||||
|
||||
def test_finalizacao_informacional_cria_protocolo_fechado_sem_duplicar_status():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Client:
|
||||
def __init__(self): self.protocol_calls = []; self.status_calls = []
|
||||
def abrir_protocolo(self, payload):
|
||||
self.protocol_calls.append(payload)
|
||||
return {"protocolNumber": "INFO-123"}
|
||||
def status_sr(self, payload):
|
||||
self.status_calls.append(payload)
|
||||
return {"ok": True}
|
||||
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
client = Client()
|
||||
reg = build_contas_workflow_actions(ContasDomainService(client=client))
|
||||
action = reg.get("finalizar_atendimento_action")
|
||||
result = action({
|
||||
"status": "resolvido",
|
||||
"summary": "Explicação aceita.",
|
||||
"msisdn": "11999999999",
|
||||
"social_sec_no": "12345678901",
|
||||
"informational_vas_types": ["bundle", "estrategico"],
|
||||
}, {"input": {}})
|
||||
assert result["protocol_number"] == "INFO-123"
|
||||
assert result["informational_protocol_notes"] == "Explicação de VAS Bundle, VAS Estratégico"
|
||||
assert client.protocol_calls[0]["requestStatus"] == "Fechado"
|
||||
assert client.protocol_calls[0]["status"] == "CLOSED"
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Bundle, VAS Estratégico"
|
||||
assert client.status_calls == []
|
||||
|
||||
|
||||
def test_finalizacao_com_protocolo_existente_nao_cria_informacional():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class Client:
|
||||
def __init__(self): self.protocol_calls = []; self.status_calls = []
|
||||
def abrir_protocolo(self, payload):
|
||||
self.protocol_calls.append(payload)
|
||||
return {"protocolNumber": "NOVO"}
|
||||
def status_sr(self, payload):
|
||||
self.status_calls.append(payload)
|
||||
return {"ok": True}
|
||||
|
||||
client = Client()
|
||||
action = build_contas_workflow_actions(ContasDomainService(client=client)).get("finalizar_atendimento_action")
|
||||
result = action({
|
||||
"status": "resolvido",
|
||||
"summary": "ok",
|
||||
"msisdn": "11999999999",
|
||||
"protocol_number": "PRT-EXISTENTE",
|
||||
"informational_vas_types": ["avulso"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls == []
|
||||
assert client.status_calls[0]["protocolNumber"] == "PRT-EXISTENTE"
|
||||
assert "informational_protocol_notes" not in result
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("types", "expected"),
|
||||
[
|
||||
({"bundle"}, "Explicação de VAS Bundle"),
|
||||
({"estrategico"}, "Explicação de VAS Estratégico"),
|
||||
({"avulso"}, "Explicação de VAS Avulso"),
|
||||
({"bundle", "estrategico"}, "Explicação de VAS Bundle, VAS Estratégico"),
|
||||
({"bundle", "avulso"}, "Explicação de VAS Bundle, VAS Avulso"),
|
||||
({"estrategico", "avulso"}, "Explicação de VAS Estratégico, VAS Avulso"),
|
||||
({"bundle", "estrategico", "avulso"}, "Explicação de VAS Bundle, VAS Estratégico, VAS Avulso"),
|
||||
],
|
||||
)
|
||||
def test_finalizacao_informacional_notas_canonicas(types, expected):
|
||||
from app.domain.contas.workflow_actions import _format_informational_vas_notes
|
||||
|
||||
assert _format_informational_vas_notes(types) == expected
|
||||
|
||||
|
||||
def test_finalizacao_cria_protocolo_informacional_quando_invoice_explanation_executou():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.protocol_calls = []
|
||||
def abrir_protocolo(self, payload):
|
||||
self.protocol_calls.append(payload)
|
||||
return {"interactionProtocol": "INFO-123"}
|
||||
def status_sr(self, payload):
|
||||
raise AssertionError("protocolo informacional fechado nao deve ser fechado novamente")
|
||||
class Service:
|
||||
def __init__(self): self.client = Client()
|
||||
def finalizar_atendimento(self, *, status, summary, protocol="", msisdn="", **kwargs):
|
||||
return {"success": True, "status": status, "summary": summary}
|
||||
|
||||
service = Service()
|
||||
reg = build_contas_workflow_actions(service) # type: ignore[arg-type]
|
||||
action = reg.get("finalizar_atendimento_action")
|
||||
result = action({
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente entendeu a explicação.",
|
||||
"msisdn": "11999999999",
|
||||
"social_sec_no": "12345678901",
|
||||
"message_id": "msg-123",
|
||||
"invoice_explanation_base": "Explicação dos valores da fatura.",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
}, {"input": {}})
|
||||
assert result["protocol_number"] == "INFO-123"
|
||||
assert service.client.protocol_calls[0]["serviceRequestNotes"] == "Explicação dos valores da fatura"
|
||||
|
||||
|
||||
def test_finalizacao_nao_cria_protocolo_apenas_por_prefetch_invoice_explanation():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client:
|
||||
def __init__(self): self.protocol_calls = []
|
||||
def abrir_protocolo(self, payload): self.protocol_calls.append(payload); return {"interactionProtocol": "BAD"}
|
||||
class Service:
|
||||
def __init__(self): self.client = Client()
|
||||
def finalizar_atendimento(self, *, status, summary, protocol="", msisdn="", **kwargs): return {"success": True}
|
||||
service = Service()
|
||||
action = build_contas_workflow_actions(service).get("finalizar_atendimento_action") # type: ignore[arg-type]
|
||||
result = action({"status": "resolvido", "msisdn": "119", "invoice_explanation_base": "prefetch"}, {"input": {}})
|
||||
assert "protocol_number" not in result
|
||||
assert service.client.protocol_calls == []
|
||||
|
||||
|
||||
def test_finalizacao_nao_duplica_protocolo_quando_contestacao_ja_existe():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client:
|
||||
def __init__(self): self.protocol_calls = []; self.status_calls=[]
|
||||
def abrir_protocolo(self, payload): self.protocol_calls.append(payload); return {"interactionProtocol": "BAD"}
|
||||
def status_sr(self, payload): self.status_calls.append(payload); return {"ok": True}
|
||||
class Service:
|
||||
def __init__(self): self.client = Client()
|
||||
def finalizar_atendimento(self, *, status, summary, protocol="", msisdn="", **kwargs):
|
||||
if protocol: self.client.status_sr({"protocolNumber": protocol, "status": "Fechado"})
|
||||
return {"success": True}
|
||||
service = Service()
|
||||
action = build_contas_workflow_actions(service).get("finalizar_atendimento_action") # type: ignore[arg-type]
|
||||
result = action({
|
||||
"status": "resolvido", "msisdn": "119", "informational_vas_types": ["avulso"],
|
||||
"contestacao_protocol": "2026000083899",
|
||||
}, {"input": {}})
|
||||
assert "protocol_number" not in result
|
||||
assert service.client.protocol_calls == []
|
||||
|
||||
|
||||
def test_finalizacao_reusa_protocolo_veb_ja_fechado_sem_abrir_outro():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client:
|
||||
def __init__(self): self.protocol_calls=[]; self.status_calls=[]
|
||||
def abrir_protocolo(self, payload): self.protocol_calls.append(payload); return {"interactionProtocol":"BAD"}
|
||||
def status_sr(self, payload): self.status_calls.append(payload); return {"ok":True}
|
||||
class Service:
|
||||
def __init__(self): self.client=Client()
|
||||
def finalizar_atendimento(self, *, status, summary, protocol="", msisdn="", **kwargs):
|
||||
if protocol: self.client.status_sr({"protocolNumber":protocol})
|
||||
return {"success":True}
|
||||
svc=Service(); action=build_contas_workflow_actions(svc).get("finalizar_atendimento_action") # type: ignore[arg-type]
|
||||
result=action({
|
||||
"status":"resolvido", "msisdn":"119", "protocol_closed":True,
|
||||
"protocolos_por_linha":[{"msisdn":"119","protocolo_id":"VEB-123"}],
|
||||
"informational_vas_types":["estrategico"],
|
||||
}, {"input":{}})
|
||||
assert result["protocol_number"] == "VEB-123"
|
||||
assert svc.client.protocol_calls == []
|
||||
assert svc.client.status_calls == []
|
||||
|
||||
|
||||
def test_finalizacao_force_rt15_cria_novo_protocolo_mesmo_com_veb_fechado():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client:
|
||||
def __init__(self): self.protocol_calls=[]
|
||||
def abrir_protocolo(self,payload): self.protocol_calls.append(payload); return {"interactionProtocol":"INFO-RT15"}
|
||||
class Service:
|
||||
def __init__(self): self.client=Client()
|
||||
def finalizar_atendimento(self, **kwargs): return {"success":True}
|
||||
svc=Service(); action=build_contas_workflow_actions(svc).get("finalizar_atendimento_action") # type: ignore[arg-type]
|
||||
result=action({
|
||||
"status":"resolvido", "msisdn":"119", "protocol_closed":True,
|
||||
"protocolos_por_linha":[{"protocolo_id":"VEB-123"}],
|
||||
"informational_vas_types":["bundle"], "force_rt15_finalization_protocol":True,
|
||||
}, {"input":{}})
|
||||
assert result["protocol_number"] == "INFO-RT15"
|
||||
assert len(svc.client.protocol_calls) == 1
|
||||
assert svc.client.protocol_calls[0]["serviceRequestNotes"].endswith("VAS Bundle")
|
||||
|
||||
|
||||
def test_finalizacao_veb_deferido_suprime_evento_cvn_de_protocolo():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client:
|
||||
def abrir_protocolo(self,payload): return {"interactionProtocol":"VEB-NEW"}
|
||||
class Service:
|
||||
def __init__(self): self.client=Client()
|
||||
def finalizar_atendimento(self, **kwargs): return {"success":True}
|
||||
action=build_contas_workflow_actions(Service()).get("finalizar_atendimento_action") # type: ignore[arg-type]
|
||||
result=action({
|
||||
"status":"resolvido", "msisdn":"119", "informational_vas_types":["estrategico"],
|
||||
"vas_estrategico_protocol_deferred_to_finalization":True, "suppress_cvn_protocol_ic":True,
|
||||
}, {"input":{}})
|
||||
codes=[e.get("code") if isinstance(e,dict) else str(e) for e in result["business_events"]]
|
||||
assert result["protocol_number"] == "VEB-NEW"
|
||||
assert "CVN.010" not in codes and "CVN.011" not in codes
|
||||
|
||||
|
||||
def test_invoice_explanation_converte_retry_transport_em_rct_por_tentativa():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client: pass
|
||||
class Service:
|
||||
client = Client()
|
||||
def invoice_explanation(self, *, msisdn, **params):
|
||||
return {"billing_analysis": {
|
||||
"invoiceExplanation": "Sua fatura mudou por um serviço adicional.",
|
||||
"_transport": {"rct_operation": "base_conhecimento", "attempts": [
|
||||
{"attempt": 1, "success": False, "status_code": 500, "latency_ms": 12},
|
||||
{"attempt": 2, "success": True, "status_code": 200, "latency_ms": 7},
|
||||
]},
|
||||
}}
|
||||
action=build_contas_workflow_actions(Service()).get("preparar_invoice_explanation") # type: ignore[arg-type]
|
||||
result=action({"msisdn":"119"}, {"input":{"msisdn":"119"}})
|
||||
codes=[e.get("code") for e in result["business_events"] if isinstance(e,dict)]
|
||||
assert "RCT.080" in codes
|
||||
assert "RCT.081" in codes
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
def test_invoice_explanation_falha_preserva_rct_das_tres_tentativas():
|
||||
from app.domain.contas.client import TimApiError
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client: pass
|
||||
class Service:
|
||||
client=Client()
|
||||
def invoice_explanation(self, *, msisdn, **params):
|
||||
raise TimApiError("gateway indisponivel", status_code=550, body={"message":"erro"}, attempts=[
|
||||
{"attempt":1,"success":False,"status_code":500,"latency_ms":3},
|
||||
{"attempt":2,"success":False,"status_code":502,"latency_ms":4},
|
||||
{"attempt":3,"success":False,"status_code":550,"latency_ms":5},
|
||||
])
|
||||
action=build_contas_workflow_actions(Service()).get("preparar_invoice_explanation") # type: ignore[arg-type]
|
||||
result=action({"msisdn":"119"}, {"input":{"msisdn":"119"}})
|
||||
codes=[e.get("code") for e in result["business_events"] if isinstance(e,dict)]
|
||||
assert [c for c in codes if str(c).startswith("RCT.")] == ["RCT.080","RCT.082","RCT.084"]
|
||||
assert result["api_status_code"] == 550
|
||||
assert result["service_failed"] is True
|
||||
|
||||
|
||||
def _event_codes(value):
|
||||
return [x.get("code") if isinstance(x, dict) else str(x) for x in (value.get("business_events") or [])]
|
||||
|
||||
|
||||
def test_cancelamento_batch_emite_vaa_caminho_feliz_uma_vez():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Client:
|
||||
def consultar_vas(self, msisdn):
|
||||
return {"services": [{"bill_description": "TIM Fashion Mensal", "app_id": "A1", "can": {"cancel": True}}]}
|
||||
def line_info(self, msisdn): return {"socialSecNo": "123"}
|
||||
def abrir_protocolo(self, payload): return {"protocol": "P-1"}
|
||||
class Service:
|
||||
client = Client()
|
||||
def cancelar_vas_avulso(self, **kwargs):
|
||||
return {"success": True, "msisdn": kwargs["msisdn"], "subject": kwargs["subject"], "protocol": kwargs.get("protocol", "")}
|
||||
from agent_framework.idempotency import InMemoryIdempotencyStore
|
||||
action = build_contas_workflow_actions(Service(), idempotency_store=InMemoryIdempotencyStore()).get("cancelamento_vas_avulso_batch") # type: ignore[arg-type]
|
||||
import asyncio
|
||||
result = asyncio.run(action({"msisdn": "5511999999999", "items": [{"msisdn": "5511999999999", "service": "TIM Fashion Mensal"}]}, {"input": {}}))
|
||||
codes = _event_codes(result)
|
||||
assert codes.count("VAA.001") == 1
|
||||
assert codes.count("VAA.002") == 1
|
||||
assert codes.count("VAA.003") == 1
|
||||
assert "VAA.004" not in codes
|
||||
vaa = [x for x in result["business_events"] if isinstance(x, dict) and str(x.get("code", "")).startswith("VAA.")]
|
||||
assert all(x["payload"]["gsm"] == "5511999999999" for x in vaa)
|
||||
|
||||
|
||||
def test_cancelamento_batch_emite_vaa004_quando_falha_operacional():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class Service:
|
||||
class Client:
|
||||
def line_info(self, msisdn): return {"socialSecNo": "123"}
|
||||
def abrir_protocolo(self, payload): return {"protocol": "P-1"}
|
||||
client = Client()
|
||||
def cancelar_vas_avulso(self, **kwargs):
|
||||
return {"success": False, "msisdn": kwargs["msisdn"], "subject": kwargs["subject"], "reason": "block_vas_failed", "error": "timeout"}
|
||||
from agent_framework.idempotency import InMemoryIdempotencyStore
|
||||
action = build_contas_workflow_actions(Service(), idempotency_store=InMemoryIdempotencyStore()).get("cancelamento_vas_avulso_batch") # type: ignore[arg-type]
|
||||
import asyncio
|
||||
result = asyncio.run(action({"msisdn": "5511999999999", "items": [{"msisdn": "5511999999999", "service": "TIM Fashion Mensal"}]}, {"input": {}}))
|
||||
codes = _event_codes(result)
|
||||
assert "VAA.001" in codes and "VAA.002" in codes and "VAA.004" in codes
|
||||
assert "VAA.003" not in codes
|
||||
107
tests/migration/test_contestation_business_rules_full.py
Normal file
107
tests/migration/test_contestation_business_rules_full.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.domain.contas.contestation_rules import (
|
||||
complete_invoices_context, resolve_refund_option, billing_cutoff_reference,
|
||||
next_cutoff_from_cut_date, due_day, manual_conta_certa_from_evidence,
|
||||
)
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
def _complete(status='Em aberto', payment_type=2, cut_date=None):
|
||||
row={'invoiceId':'I1','invoiceStatus':status,'dueDate':'2026-08-25'}
|
||||
if cut_date is not None: row['cutDate']=cut_date
|
||||
return {'billingProfile':{'paymentTypeId':payment_type},'paymentItems':[row]}
|
||||
|
||||
|
||||
def test_refund_boleto_para_fatura_em_aberto_com_fatura_normal():
|
||||
c=complete_invoices_context(_complete('Em aberto',2),'I1')
|
||||
r=resolve_refund_option(payment_method=c['payment_method'],has_open_bill=c['has_open_bill'],invoice_statuses=c['invoice_statuses'])
|
||||
assert r['refund_option']=='1' and r['format_text']=='sms'
|
||||
|
||||
|
||||
def test_refund_credito_para_dacc_cartao_ou_parcelamento():
|
||||
for method in ('DACC|Cartão|Parcelamento','debito_automatico','cartao_credito'):
|
||||
r=resolve_refund_option(payment_method=method,has_open_bill=True,invoice_statuses=['Em aberto'])
|
||||
assert r['refund_option']=='0'
|
||||
|
||||
|
||||
def test_refund_credito_para_fatura_paga_ou_ja_contestada():
|
||||
for status in ('Pago','Contestada'):
|
||||
r=resolve_refund_option(payment_method='fatura',has_open_bill=False,invoice_statuses=[status])
|
||||
assert r['refund_option']=='0'
|
||||
|
||||
|
||||
def test_cutoff_referencia_corrige_bug_mes_seguinte():
|
||||
ref=datetime(2026,8,18,12)
|
||||
passed, cutoff=billing_cutoff_reference(10,reference_datetime=ref)
|
||||
assert passed is True and cutoff.date().isoformat()=='2026-08-10'
|
||||
passed2, cutoff2=billing_cutoff_reference(25,reference_datetime=ref)
|
||||
assert passed2 is True and cutoff2.date().isoformat()=='2026-07-25'
|
||||
|
||||
|
||||
def test_cutdate_regra_um_mes():
|
||||
ref=datetime(2026,8,18)
|
||||
assert next_cutoff_from_cut_date(datetime(2026,6,1),reference_datetime=ref)[0] is True
|
||||
assert next_cutoff_from_cut_date(datetime(2026,8,1),reference_datetime=ref)[0] is False
|
||||
|
||||
|
||||
def test_due_day_iso_e_br():
|
||||
assert due_day('2026-08-25')==25
|
||||
assert due_day('25/08/2026')==25
|
||||
|
||||
|
||||
def test_dependente_forca_conta_certa_manual():
|
||||
assert manual_conta_certa_from_evidence(dependent_invoice_item=True) is True
|
||||
|
||||
|
||||
def test_cutdate_da_fatura_define_manual():
|
||||
ref=datetime(2026,8,18)
|
||||
assert manual_conta_certa_from_evidence(invoice_item={'cutDate':'2026-06-01'},reference_datetime=ref) is True
|
||||
assert manual_conta_certa_from_evidence(invoice_item={'cutDate':'2026-08-01'},reference_datetime=ref) is False
|
||||
|
||||
|
||||
def test_check_invoice_status_enriquece_prefetch_sem_nova_api():
|
||||
class Client:
|
||||
def consultar_faturas(self,*a,**k): raise AssertionError('não deve chamar API')
|
||||
class Service: client=Client()
|
||||
a=build_contas_workflow_actions(Service()).get('check_invoice_status')
|
||||
out=a({'complete_invoices_payload':_complete('Em aberto',3),'invoice_id':'I1'}, {'input':{},'vars':{}})
|
||||
assert out['success'] is True
|
||||
assert out['invoice_status']=='Em aberto'
|
||||
assert out['has_open_bill'] is True
|
||||
assert out['payment_type_id']==3
|
||||
assert out['requires_sms'] is True
|
||||
|
||||
|
||||
def test_check_invoice_status_falha_sem_prefetch():
|
||||
class Service: pass
|
||||
a=build_contas_workflow_actions(Service()).get('check_invoice_status')
|
||||
try:
|
||||
a({'invoice_id':'I1'},{'input':{},'vars':{}})
|
||||
except RuntimeError as exc:
|
||||
assert 'prefetch' in str(exc)
|
||||
else:
|
||||
raise AssertionError('deveria falhar sem prefetch')
|
||||
|
||||
|
||||
def test_abrir_contestacao_deriva_refund_manual_e_normaliza_barcode_zero():
|
||||
captured={}
|
||||
class Client:
|
||||
def billing_analysis(self,msisdn): return {'119':{'SVA Detalhe Total':[{'desc':'VAS Musica','value':29.90}]}}
|
||||
def contrato(self,msisdn): return {'billing_profile':{'date':{'cutoffDay':1}}}
|
||||
def contestar(self,payload):
|
||||
captured.update(payload)
|
||||
return {'itemsResponse':[{'status':'CRIAR','itemName':'VAS Musica','correctAccountStatus':'CRIAR'}],'barcode':'0'*44}
|
||||
class Service: client=Client()
|
||||
a=build_contas_workflow_actions(Service()).get('abrir_contestacao_cliente')
|
||||
complete=_complete('Em aberto',2)
|
||||
state={'input':{},'vars':{'check_invoice_status':{'complete_invoices_payload':complete,**complete_invoices_context(complete,'I1')}}}
|
||||
out=a({'msisdn':'119','social_sec_no':'123','customer_id':'C1','invoice_id':'I1','items':[{'itemName':'VAS Musica','claimedAmount':'29,90','validatedAmount':'29,90'}],'protocolo_id':'P1','skip_invoice_item_validation':True},state)
|
||||
assert captured['refundOption']=='1'
|
||||
assert captured['manualContaCertaIndicator'] is True
|
||||
assert captured['contestationType']=='0'
|
||||
assert captured['adjustReason']=='SERVICO_NAO_SOLICITADO'
|
||||
assert out['barcode']==''
|
||||
assert out['conta_certa_status']=='CRIAR'
|
||||
55
tests/migration/test_contestation_failure_mapping.py
Normal file
55
tests/migration/test_contestation_failure_mapping.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
|
||||
from agent_framework.workflows.models import WorkflowRunResult
|
||||
import contas_mcp.servers.contas_mcp_server.main as mcp_main
|
||||
|
||||
|
||||
class Runtime:
|
||||
def __init__(self, result):
|
||||
self.result = result
|
||||
async def arun(self, name, payload, execution_id=None):
|
||||
return self.result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contestacao_failed_preserva_provider_message_e_protocolo(monkeypatch):
|
||||
result = WorkflowRunResult(
|
||||
execution_id="e1",
|
||||
workflow_name="contestacao_tool",
|
||||
workflow_version=2,
|
||||
status="FAILED",
|
||||
error="500 provider",
|
||||
error_details={
|
||||
"status_code": 550,
|
||||
"body": {
|
||||
"description": "Itens já contestados",
|
||||
"provider": {"errorMessage": "Impossível prosseguir, pois os itens já foram contestados."},
|
||||
},
|
||||
"attempts": [{"attempt": 1, "success": False}],
|
||||
},
|
||||
output={"registrar_protocolo": {"protocolo_id": "PRT-123"}},
|
||||
state={}, trace=[],
|
||||
)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: Runtime(result))
|
||||
payload = await mcp_main._run_workflow("contestacao_tool", {"msisdn": "119", "subject": "VAS", "valor": 10})
|
||||
assert payload["success"] is False
|
||||
assert payload["contestation_error_description"].startswith("Impossível prosseguir")
|
||||
assert payload["mensagem"] == payload["contestation_error_description"]
|
||||
assert payload["contestacao_protocol"] == "PRT-123"
|
||||
assert payload["erro"] == "contestacao_nao_realizada"
|
||||
assert payload["erro_sistemico"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contestacao_failed_sem_mensagem_provider_vira_falha_sistemica(monkeypatch):
|
||||
result = WorkflowRunResult(
|
||||
execution_id="e2", workflow_name="contestacao_tool", workflow_version=2,
|
||||
status="FAILED", error="connection reset",
|
||||
error_details={"type": "ConnectionError"}, output={}, state={}, trace=[],
|
||||
)
|
||||
monkeypatch.setattr(mcp_main, "get_workflow_runtime", lambda: Runtime(result))
|
||||
payload = await mcp_main._run_workflow("contestacao_tool", {"msisdn": "119", "subject": "VAS", "valor": 10})
|
||||
assert payload["success"] is False
|
||||
assert payload["erro"] == "erro_falha_sistema"
|
||||
assert payload["erro_sistemico"] is True
|
||||
assert "contestation_error_description" not in payload
|
||||
85
tests/migration/test_dispute_actions_remaining_parity.py
Normal file
85
tests/migration/test_dispute_actions_remaining_parity.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.sms_args=None; self.status_payload=None; self.tracking_payload=None; self.protocol_payload=None
|
||||
self.fail_protocol=False; self.fail_status=False
|
||||
def sms(self, msisdn, message, **ctx):
|
||||
self.sms_args=(msisdn,message,ctx)
|
||||
return {"resource_url":"http://sms/mock","_transport":{"attempts":[{"api_url":"http://mock/sms","status_code":200,"latency_ms":1}]}}
|
||||
def status_sr(self,payload):
|
||||
self.status_payload=payload
|
||||
if self.fail_status: raise RuntimeError("status fail")
|
||||
return {"ok":True,"_transport":{"attempts":[{"api_url":"http://mock/status","status_code":200,"latency_ms":1}]}}
|
||||
def tracking(self,payload): self.tracking_payload=payload; return {"ok":True}
|
||||
def abrir_protocolo(self,payload):
|
||||
self.protocol_payload=payload
|
||||
if self.fail_protocol: raise RuntimeError("gateway indisponivel")
|
||||
return {"interactionProtocol":"CCM-1"}
|
||||
|
||||
class Service:
|
||||
def __init__(self): self.client=Client()
|
||||
|
||||
|
||||
def test_sms_usa_barcode_titular_e_payload_padrao():
|
||||
s=Service(); a=build_contas_workflow_actions(s).get("enviar_sms")
|
||||
out=a({"msisdn":"15981110643"},{"input":{"msisdn":"11964200059"},"vars":{"abrir_contestacao_cliente":{"barcode":"3419179001"}}})
|
||||
assert out["success"] is True
|
||||
msisdn,message,ctx=s.client.sms_args
|
||||
assert msisdn == "15981110643"
|
||||
assert message == "Codigo do boleto: 3419179001"
|
||||
assert ctx["sender_name"] == "TIM Brasil"
|
||||
assert ctx["long_url"] == "https://meutim.com.br"
|
||||
assert out["sms_not_send_error"] is False
|
||||
|
||||
|
||||
def test_sms_falha_nao_interrompe_fluxo_e_marca_flag():
|
||||
s=Service()
|
||||
def fail(*args,**kwargs): raise RuntimeError("sms fail")
|
||||
s.client.sms=fail
|
||||
a=build_contas_workflow_actions(s).get("enviar_sms")
|
||||
out=a({"msisdn":"119"},{"input":{}})
|
||||
assert out["success"] is True
|
||||
assert out["sms_sent"] is False
|
||||
assert out["sms_not_send_error"] is True
|
||||
|
||||
|
||||
def test_conta_certa_manual_falha_api_e_best_effort():
|
||||
s=Service(); s.client.fail_protocol=True
|
||||
a=build_contas_workflow_actions(s).get("abrir_sr_conta_certa_manual")
|
||||
out=a({"msisdn":"119","social_sec_no":"123","dia_vencimento":"20"},{"input":{}})
|
||||
assert out["success"] is True
|
||||
assert out["status"] == "Encaminhado"
|
||||
assert out["sr_conta_certa_id"] == ""
|
||||
assert out["conta_certa_manual_error"] is True
|
||||
assert s.client.protocol_payload["reason3"].endswith("20")
|
||||
|
||||
|
||||
def test_status_sr_sms_path_e_tracking_completo():
|
||||
s=Service(); a=build_contas_workflow_actions(s).get("atualizar_status_sr")
|
||||
state={"input":{},"vars":{
|
||||
"registrar_protocolo":{"protocolo_id":"PRT-10"},
|
||||
"enviar_sms":{"sms_sent":True,"sms_not_send_error":False},
|
||||
"check_invoice_status":{"open_invoices":[{"invoiceId":"300","invoiceStatus":"A Vencer","issueDate":"2026-06-01","dueDate":"2026-06-20","openAmount":"349.29","amount":"349.29"}]},
|
||||
}}
|
||||
out=a({"msisdn":"119","scenario":"contestacao","current_invoice_number":"300","social_sec_no":"123.456.789-01","invoice_amount_open":"349.29","invoice_amount":"349.29"},state)
|
||||
assert out["success"] is True and out["protocol_closed"] is True
|
||||
assert out["format_text"] == "sms" and out["resolution_type"] == "new_boleto"
|
||||
assert s.client.status_payload["reason1"] == "Reclamação"
|
||||
assert s.client.status_payload["reason2"] == "Conta"
|
||||
assert s.client.status_payload["reason3"] == "Valor"
|
||||
assert s.client.tracking_payload["protocolNumber"] == "PRT-10"
|
||||
assert s.client.tracking_payload["invoiceEmissionDate"] == "2026-06-01T00:00:00.00Z"
|
||||
assert s.client.tracking_payload["invoiceExpirationDate"] == "2026-06-20T00:00:00.00Z"
|
||||
assert s.client.tracking_payload["invoiceStatus"] == "Aberto"
|
||||
|
||||
|
||||
def test_status_sr_nao_faz_tracking_de_fatura_errada():
|
||||
s=Service(); a=build_contas_workflow_actions(s).get("atualizar_status_sr")
|
||||
state={"vars":{"registrar_protocolo":{"protocolo_id":"P"},"check_invoice_status":{"open_invoices":[{"invoiceId":"OTHER"}]}}}
|
||||
out=a({"msisdn":"119","scenario":"contestacao","current_invoice_number":"TARGET"},state)
|
||||
assert out["success"] is True
|
||||
assert s.client.tracking_payload is None
|
||||
324
tests/migration/test_finalization_parity_extended.py
Normal file
324
tests/migration/test_finalization_parity_extended.py
Normal file
@@ -0,0 +1,324 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *, protocol="INFO-123", fail=False):
|
||||
self.protocol = protocol
|
||||
self.fail = fail
|
||||
self.protocol_calls = []
|
||||
self.status_calls = []
|
||||
def abrir_protocolo(self, payload):
|
||||
self.protocol_calls.append(dict(payload))
|
||||
if self.fail:
|
||||
raise RuntimeError("falha protocolo")
|
||||
return {"interactionProtocol": self.protocol}
|
||||
def status_sr(self, payload):
|
||||
self.status_calls.append(dict(payload)); return {"ok": True}
|
||||
|
||||
|
||||
def _action(client):
|
||||
service = ContasDomainService(client=client)
|
||||
reg = build_contas_workflow_actions(service)
|
||||
return reg.get("finalizar_atendimento_action")
|
||||
|
||||
|
||||
def _codes(result):
|
||||
return [x["code"] for x in result.get("business_events") or []]
|
||||
|
||||
|
||||
def test_invoice_explanation_finalization_orders_events_and_returns_alias():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "Atendimento concluído.",
|
||||
"msisdn": "11999999999", "social_sec_no": "123", "message_id": "m1",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Explicação dos valores da fatura.",
|
||||
}, {"input": {}})
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
assert _codes(result) == ["CVN.008", "RCT.085", "CVN.010", "MPI.006", "SAD.001"]
|
||||
|
||||
|
||||
def test_invoice_explanation_negative_emits_cvn009_mpi005_without_protocol_when_no_notes():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "nao_resolvido", "summary": "Cliente não concordou.",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls == []
|
||||
assert _codes(result) == ["CVN.009", "MPI.005", "SAD.001"]
|
||||
|
||||
|
||||
def test_handoff_suppression_emits_only_sad001():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "nao_resolvido", "summary": "ATH",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Explicação.",
|
||||
"suppress_cvn_009_on_finalize": True,
|
||||
"suppress_cvn_lookup_on_finalize": True,
|
||||
"suppress_informational_protocol_on_finalize": True,
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls == []
|
||||
assert _codes(result) == ["SAD.001"]
|
||||
|
||||
|
||||
def test_protocol_failure_does_not_fail_finalization_and_emits_negative_events():
|
||||
client = _Client(fail=True)
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "ok", "msisdn": "119",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Explicação da fatura.",
|
||||
}, {"input": {}})
|
||||
assert result["success"] is True
|
||||
assert "finalizacao_protocol" not in result
|
||||
assert _codes(result) == ["CVN.008", "RCT.086", "CVN.011", "MPI.006", "SAD.001"]
|
||||
|
||||
|
||||
def test_transactional_workflow_does_not_emit_informational_acceptance():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "concluído", "protocol_closed": True,
|
||||
"protocol_number": "PRT-1", "business_workflows_executed": ["contestacao_tool"],
|
||||
}, {"input": {}})
|
||||
assert _codes(result) == ["SAD.001"]
|
||||
|
||||
|
||||
def test_sad_decision_tree_handoff_second_time():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "nao_resolvido", "summary": "ATH",
|
||||
"sad_exit_decision_enabled": True, "human_handoff": True, "ath_first_time": False,
|
||||
}, {"input": {}})
|
||||
assert _codes(result) == ["SAD.001", "SAD.003", "SAD.004", "SAD.006"]
|
||||
|
||||
def test_invoice_explanation_uses_canonical_notes_not_free_text():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "ok", "msisdn": "119",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Resumo qualquer vindo do prefetch e do LLM.",
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação dos valores da fatura"
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
|
||||
|
||||
def test_strategic_partner_is_classified_without_invoice_detail():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "ok", "msisdn": "119",
|
||||
"informational_service_names": ["Paramount+ Mensal"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Estratégico"
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
|
||||
|
||||
def test_known_avulso_is_classified_without_invoice_detail():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "ok", "msisdn": "119",
|
||||
"informational_service_names": ["TIM Fashion Mensal"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Avulso"
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
|
||||
|
||||
def test_prefetch_invoice_context_guarantees_lookup_and_rt15_without_latched_workflow():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido_outros_assuntos",
|
||||
"summary": "Encerramento realizado pelo agente.",
|
||||
"msisdn": "11999999999",
|
||||
"invoice_id": "3000131181",
|
||||
"invoice_explanation_base": "Resumo da fatura carregado no prefetch.",
|
||||
}, {"input": {}})
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação dos valores da fatura"
|
||||
assert _codes(result) == ["CVN.002", "CVN.006", "CVN.008", "RCT.085", "CVN.010", "MPI.006", "SAD.001"]
|
||||
|
||||
|
||||
def test_unresolved_transition_does_not_reemit_positive_cvn_or_mpi():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente aceitou no fluxo de negócio.",
|
||||
"invoice_id": "3000131182",
|
||||
"conversation_unresolved_transition_emitted": True,
|
||||
"invoice_explanation_base": "Resumo da fatura.",
|
||||
}, {"input": {}})
|
||||
assert _codes(result) == ["SAD.001"]
|
||||
|
||||
|
||||
def test_terminal_veb_resolution_suppresses_cvn_mpi_and_duplicate_rt15():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente aceitou a explicação.",
|
||||
"protocol_number": "VEB-123",
|
||||
"protocol_closed": True,
|
||||
"conversation_explanation_resolved": True,
|
||||
"suppress_cvn_protocol_ic": True,
|
||||
"informational_vas_types": ["bundle"],
|
||||
"informational_service_names": ["Pacote Europa Promocional"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls == []
|
||||
assert result["protocol_number"] == "VEB-123"
|
||||
assert _codes(result) == ["SAD.001"]
|
||||
|
||||
|
||||
def test_invoice_detail_conflict_overrides_saved_informational_type():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "ok", "msisdn": "119",
|
||||
"informational_service_names": ["TIM Fashion Mensal"],
|
||||
"informational_vas_types": ["bundle"],
|
||||
"invoice_detail": {"119": {"SVA Detalhe Total": [{"desc": "TIM Fashion Mensal", "value": 19.9, "classe": "avulso"}]}},
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Avulso"
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
|
||||
|
||||
def test_invoice_detail_without_match_ignores_saved_type_and_does_not_create_protocol():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "ok", "msisdn": "119",
|
||||
"informational_service_names": ["Serviço que não consta"],
|
||||
"informational_vas_types": ["bundle"],
|
||||
"invoice_detail": {"119": {"Serviços Bundle Inclusos": [{"desc": "Outro serviço", "value": 0}]}},
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls == []
|
||||
assert "finalizacao_protocol" not in result
|
||||
|
||||
|
||||
def test_rag_saved_type_is_used_when_invoice_detail_is_absent():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "ok", "msisdn": "119",
|
||||
"informational_service_names": ["Serviço explicado pelo RAG"],
|
||||
"informational_rag_vas_types": ["bundle"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Bundle"
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
|
||||
|
||||
def test_partial_invoice_inference_merges_saved_avulso_type():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "Cliente entendeu a explicação e agradeceu.", "msisdn": "119",
|
||||
"informational_service_names": ["Pacote Europa Promocional", "YouTube Premium Mensal", "Aluguel de Filme 3"],
|
||||
"informational_vas_types": ["avulso"],
|
||||
"invoice_detail": {"119": {
|
||||
"Serviços Bundle Inclusos": [{"desc": "Pacote Europa Promocional", "value": 0}],
|
||||
"Serviços Contratados de Terceiros": [{"desc": "YouTube Premium Mensal", "value": 24.9, "classe": "estrategico"}],
|
||||
}},
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Bundle, VAS Estratégico, VAS Avulso"
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
|
||||
|
||||
def test_existing_session_protocol_prevents_duplicate_informational_rt15():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "Cliente entendeu a explicação.",
|
||||
"customer_protocols": [{"number": "PRT-EXISTENTE"}],
|
||||
"informational_service_names": ["Paramount+ Mensal"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls == []
|
||||
assert "finalizacao_protocol" not in result
|
||||
|
||||
|
||||
def test_cpf_alias_is_forwarded_to_informational_protocol():
|
||||
client = _Client()
|
||||
_action(client)({
|
||||
"status": "resolvido", "summary": "Cliente entendeu a explicação.",
|
||||
"msisdn": "11999999999", "cpf": "12345678901",
|
||||
"informational_service_names": ["Paramount+ Mensal"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["socialSecNo"] == "12345678901"
|
||||
|
||||
|
||||
def test_three_informational_vas_types_have_canonical_order():
|
||||
client = _Client()
|
||||
_action(client)({
|
||||
"status": "resolvido", "summary": "Cliente entendeu.", "msisdn": "119",
|
||||
"informational_vas_types": ["avulso", "bundle", "estrategico"],
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Bundle, VAS Estratégico, VAS Avulso"
|
||||
|
||||
|
||||
def test_free_customer_phrase_without_evidence_does_not_create_protocol():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "Cliente entendeu a explicação.",
|
||||
"customer_message": "o que é incluído no plano, mas está sendo cobrado?",
|
||||
}, {"input": {}})
|
||||
assert client.protocol_calls == []
|
||||
assert "finalizacao_protocol" not in result
|
||||
|
||||
|
||||
def test_legacy_final_alias_normalizes_to_resolved_without_spurious_protocol():
|
||||
client = _Client()
|
||||
result = _action(client)({"status": "final", "summary": ""}, {"input": {}})
|
||||
assert result["status"] == "resolvido"
|
||||
assert client.protocol_calls == []
|
||||
|
||||
|
||||
def test_transactional_cancel_finalization_does_not_emit_cvn_or_mpi_even_with_summary():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido", "summary": "Cancelamento concluído.",
|
||||
"business_workflows_executed": ["cancelamento_vas_avulso"],
|
||||
"protocol_number": "CAN-123", "protocol_closed": True,
|
||||
}, {"input": {}})
|
||||
assert _codes(result) == ["SAD.001"]
|
||||
|
||||
|
||||
def test_strategic_notes_take_priority_over_prior_invoice_explanation_notes():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente entendeu a explicação e agradeceu.",
|
||||
"msisdn": "11999999999",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Explicação dos valores da fatura.",
|
||||
"informational_service_names": ["YouTube Premium Mensal"],
|
||||
"informational_vas_types": ["estrategico"],
|
||||
}, {"input": {}})
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Estratégico"
|
||||
|
||||
|
||||
def test_deferred_bundle_and_strategic_items_create_combined_rt15_without_workflow_latch():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente entendeu a explicação e agradeceu.",
|
||||
"msisdn": "11999999999",
|
||||
"protocol_deferred_to_finalization": True,
|
||||
"informational_service_names": ["Pacote Europa Promocional", "YouTube Premium Mensal"],
|
||||
"informational_vas_types": ["bundle", "estrategico"],
|
||||
"invoice_detail": {
|
||||
"11999999999": {
|
||||
"Serviços Bundle Inclusos": [{"desc": "Pacote Europa Promocional", "value": 0}],
|
||||
"Serviços Contratados de Terceiros": [{"desc": "YouTube Premium Mensal", "value": 24.9, "classe": "estrategico"}],
|
||||
}
|
||||
},
|
||||
}, {"input": {}})
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação de VAS Bundle, VAS Estratégico"
|
||||
|
||||
|
||||
def test_invoice_explanation_only_registers_protocol_at_finalization():
|
||||
client = _Client()
|
||||
result = _action(client)({
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente aceitou a explicação.",
|
||||
"msisdn": "11999999999",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Explicação dos valores da fatura.",
|
||||
}, {"input": {}})
|
||||
assert len(client.protocol_calls) == 1
|
||||
assert result["finalizacao_protocol"] == "INFO-123"
|
||||
assert client.protocol_calls[0]["serviceRequestNotes"] == "Explicação dos valores da fatura"
|
||||
30
tests/migration/test_framework_llm_composition_directive.py
Normal file
30
tests/migration/test_framework_llm_composition_directive.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
def test_framework_detecta_instrucao_de_composicao_llm_recursiva():
|
||||
results=[{"ok":True,"result":{"output":{"orientar":{"requires_llm_composition":True,"response_instruction":"Explique a devolução."}}}}]
|
||||
required, instructions = AgentRuntimeMixin._mcp_llm_composition_directive(results)
|
||||
assert required is True
|
||||
assert instructions == ["Explique a devolução."]
|
||||
|
||||
|
||||
def test_direct_answer_eh_desabilitada_quando_tool_exige_composicao_llm():
|
||||
runtime=AgentRuntimeMixin()
|
||||
results=[{"ok":True,"tool_name":"pro_rata","result":{"requires_llm_composition":True,"response_instruction":"Explique."}}]
|
||||
assert runtime.build_direct_mcp_answer({"user_text":"explique"}, results, agent_label="Contas") is None
|
||||
|
||||
|
||||
def test_pro_rata_orientacao_declara_composicao_no_llm_do_framework():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Service: pass
|
||||
reg=build_contas_workflow_actions(Service()) # type: ignore[arg-type]
|
||||
result=reg.get("orientar_pagamento_pro_rata")(
|
||||
{"devolucao":{"format_text":"conta_futura","items":[{"itemName":"Plano","validatedAmount":3.06}]}},
|
||||
{"input":{}},
|
||||
)
|
||||
assert result["requires_llm_composition"] is True
|
||||
assert result["devolucao"]["format_text"] == "conta_futura"
|
||||
assert "devolução" in result["response_instruction"]
|
||||
111
tests/migration/test_framework_native_structure.py
Normal file
111
tests/migration/test_framework_native_structure.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from agent_framework.workflows import FileWorkflowRepository
|
||||
from app.domain.contas import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_domain_does_not_import_langgraph_directly():
|
||||
violations = []
|
||||
for base in (ROOT / "app", ROOT / "mcp"):
|
||||
for path in base.rglob("*.py"):
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if re.search(r"(^|\n)\s*(from\s+langgraph|import\s+langgraph)", text):
|
||||
violations.append(str(path.relative_to(ROOT)))
|
||||
assert not violations
|
||||
|
||||
|
||||
def test_official_templates_use_framework_state_graph():
|
||||
for rel in (
|
||||
"agent_framework_oci/templates/agent_template_backend/app/workflows/agent_graph.py",
|
||||
"agent_framework_oci/templates/agent_template_backend_day_zero/app/workflows/agent_graph.py",
|
||||
):
|
||||
text = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert "FrameworkStateGraph" in text
|
||||
assert "from langgraph.graph" not in text
|
||||
|
||||
|
||||
def test_all_active_workflow_actions_registered(monkeypatch):
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "true")
|
||||
registry = build_contas_workflow_actions(ContasDomainService())
|
||||
registered = set(registry._actions)
|
||||
repo = FileWorkflowRepository(ROOT / "workflows")
|
||||
active = []
|
||||
required = set()
|
||||
for marker in sorted((ROOT / "workflows").glob("*.active.yaml")):
|
||||
name = marker.name.removesuffix(".active.yaml")
|
||||
definition = repo.get_active(name)
|
||||
active.append((name, definition.version))
|
||||
required.update(node.action for node in definition.nodes)
|
||||
assert len(active) == 10
|
||||
assert required <= registered, sorted(required - registered)
|
||||
|
||||
|
||||
def test_guardrail_config_declares_all_execution_stages():
|
||||
raw = yaml.safe_load((ROOT / "config" / "guardrails.yaml").read_text(encoding="utf-8"))
|
||||
assert raw.get("input")
|
||||
assert raw.get("output")
|
||||
assert raw.get("retrieval")
|
||||
assert raw.get("tool")
|
||||
|
||||
|
||||
def test_contestacao_workflow_encerra_imediatamente_quando_cval_bloqueia():
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
wf = yaml.safe_load((Path(__file__).parents[2] / "workflows" / "contestacao_tool.v2.yaml").read_text())
|
||||
edges = wf["edges"]
|
||||
blocked = [e for e in edges if e.get("from") == "abrir_contestacao_cliente" and e.get("to") == "END"]
|
||||
assert blocked
|
||||
assert blocked[0]["priority"] == 1
|
||||
assert blocked[0]["when"] == {"eq": ["$.vars.abrir_contestacao_cliente.success", False]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_runtime_preserva_snapshot_parcial_quando_action_posterior_falha(tmp_path, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.workflows.models import WorkflowDefinition, WorkflowNode
|
||||
from agent_framework.workflows.runtime import WorkflowRuntime
|
||||
|
||||
class Repo:
|
||||
def get_active(self, name):
|
||||
return WorkflowDefinition(name=name, version=1, start="a", nodes=[WorkflowNode(id="a", action="noop")], edges=[])
|
||||
class Graph:
|
||||
async def ainvoke(self, initial, config=None):
|
||||
raise RuntimeError("API contestacao indisponivel")
|
||||
async def aget_state(self, config=None):
|
||||
return SimpleNamespace(values={
|
||||
"nodes": {"registrar_protocolo": {"protocolo_id": "PRT-123"}},
|
||||
"trace": [{"node": "registrar_protocolo", "status": "COMPLETED"}],
|
||||
"input": {"msisdn": "119"},
|
||||
})
|
||||
runtime = WorkflowRuntime(Repo()) # type: ignore[arg-type]
|
||||
monkeypatch.setattr(runtime, "_compile", lambda definition: Graph())
|
||||
result = await runtime.arun("contestacao_tool", {"msisdn": "119"}, execution_id="exec-1")
|
||||
assert result.status == "FAILED"
|
||||
assert result.output["registrar_protocolo"]["protocolo_id"] == "PRT-123"
|
||||
assert result.trace == [{"node": "registrar_protocolo", "status": "COMPLETED"}]
|
||||
assert result.state["input"]["msisdn"] == "119"
|
||||
|
||||
|
||||
def test_agent_workflow_init_realmente_inicializa_router_agentes_e_graph():
|
||||
import ast
|
||||
from pathlib import Path
|
||||
source = (Path(__file__).parents[2] / "app" / "workflows" / "agent_graph.py").read_text()
|
||||
tree = ast.parse(source)
|
||||
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "AgentWorkflow")
|
||||
init = next(n for n in cls.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == "__init__")
|
||||
assigned = set()
|
||||
for node in ast.walk(init):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id == "self":
|
||||
assigned.add(target.attr)
|
||||
assert {"router", "faturas", "vas", "contestacao", "suporte_contas", "graph"} <= assigned
|
||||
105
tests/migration/test_framework_rag_directive.py
Normal file
105
tests/migration/test_framework_rag_directive.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
class DummyAgent(AgentRuntimeMixin):
|
||||
pass
|
||||
|
||||
|
||||
class Doc:
|
||||
def __init__(self, ident: str, score: float):
|
||||
self.id = ident
|
||||
self.score = score
|
||||
|
||||
|
||||
class RagResult:
|
||||
def __init__(self, query: str):
|
||||
self.query = query
|
||||
self.latency_ms = 3
|
||||
self.documents = [Doc("d1", 0.9)]
|
||||
self.graph_neighbors = []
|
||||
self.metadata = {"rewritten": False}
|
||||
|
||||
def as_prompt_context(self):
|
||||
return "ORIENTACAO OFICIAL DO PARCEIRO"
|
||||
|
||||
|
||||
class RagService:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def retrieve(self, query, *, namespace, graph_node, rewrite):
|
||||
self.calls.append((query, namespace, graph_node, rewrite))
|
||||
return RagResult(query)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_pode_forcar_rag_e_fornecer_query_override():
|
||||
rag = RagService()
|
||||
agent = DummyAgent()
|
||||
agent.rag_service = rag
|
||||
agent.guardrail_pipeline = None
|
||||
agent.settings = SimpleNamespace(
|
||||
SKIP_RAG_WHEN_MCP_SUFFICIENT=True,
|
||||
ENABLE_RAG_QUERY_REWRITE=False,
|
||||
ENABLE_RAG_CONTEXT_COMPRESSION=False,
|
||||
)
|
||||
state = {
|
||||
"sanitized_input": "quero cancelar o paramount",
|
||||
"user_text": "quero cancelar o paramount",
|
||||
"route": "vas_agent",
|
||||
"mcp_results": [{
|
||||
"ok": True,
|
||||
"result": {
|
||||
"status": "COMPLETED",
|
||||
"output": {
|
||||
"registrar_nao": {
|
||||
"requires_rag": True,
|
||||
"rag_queries": [
|
||||
"Como cancelar Paramount+ no parceiro?",
|
||||
"Procedimento oficial Paramount+",
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
}],
|
||||
}
|
||||
context, metadata = await agent._retrieve_rag_context(state)
|
||||
assert context == "ORIENTACAO OFICIAL DO PARCEIRO"
|
||||
assert rag.calls[0][0] == "Como cancelar Paramount+ no parceiro?\nProcedimento oficial Paramount+"
|
||||
assert metadata["required_by_tool"] is True
|
||||
assert metadata["query_overridden_by_tool"] is True
|
||||
assert agent.build_direct_mcp_answer(state, state["mcp_results"], agent_label="VAS") is None
|
||||
|
||||
|
||||
def test_vas_estrategico_rejeitado_declara_rag_sem_executar_rag_no_dominio():
|
||||
class Service:
|
||||
class Client:
|
||||
def abrir_protocolo(self, payload):
|
||||
return {"interactionProtocol": "P1"}
|
||||
client = Client()
|
||||
|
||||
action = build_contas_workflow_actions(Service()).get("registrar_atendimento_vas_estrategico")
|
||||
result = action({
|
||||
"linhas": [{
|
||||
"msisdn": "11999999999",
|
||||
"bundle_names": [],
|
||||
"estrategico_names": ["Paramount+ Mensal", "YouTube Premium"],
|
||||
}],
|
||||
"mensagem_base": "orientação genérica",
|
||||
}, {
|
||||
"input": {"resposta_usuario": "NAO"},
|
||||
"execution_id": "wf1",
|
||||
"vars": {},
|
||||
})
|
||||
assert result["requires_rag"] is True
|
||||
assert result["rag_queries"] == [
|
||||
"Como cancelar o serviço Paramount+ Mensal no parceiro? Procedimento oficial de cancelamento.",
|
||||
"Como cancelar o serviço YouTube Premium no parceiro? Procedimento oficial de cancelamento.",
|
||||
]
|
||||
11
tests/migration/test_framework_zero_legacy.py
Normal file
11
tests/migration/test_framework_zero_legacy.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_framework_source_has_zero_legacy_namespace_references():
|
||||
root = Path(__file__).resolve().parents[2] / "agent_framework_oci" / "libs" / "agent_framework" / "src" / "agent_framework"
|
||||
offenders = []
|
||||
for path in root.rglob("*.py"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "agente_contas_tim" in text:
|
||||
offenders.append(str(path.relative_to(root)))
|
||||
assert offenders == []
|
||||
64
tests/migration/test_guardrail_binary_parity.py
Normal file
64
tests/migration/test_guardrail_binary_parity.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.guardrails.framework_llm_client import classify_with_framework_llm
|
||||
from agent_framework.guardrails.calibrated.llm_client import _BINARY_BLOCK_DIGIT, _BINARY_TASKS
|
||||
from agent_framework.guardrails.calibrated.prompts.pinj import build_pinj_prompt
|
||||
from agent_framework.guardrails.calibrated.prompts.revprec import build_revprec_prompt
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
def __init__(self, content: str):
|
||||
self.content = content
|
||||
self.profile_resolver = None
|
||||
|
||||
async def ainvoke(self, messages, **kwargs):
|
||||
return self.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("task", "content", "allowed"),
|
||||
[
|
||||
("PINJ", "1", True),
|
||||
("PINJ", "0", False),
|
||||
("PINJ", "prosa 1 e veredito 0", False),
|
||||
("PINJ", "sem digito", True),
|
||||
("PINJ", "", True),
|
||||
("REVPREC", "1", False),
|
||||
("REVPREC", "0", True),
|
||||
("REVPREC", "Resposta: 1", False),
|
||||
("REVPREC", "não sei", True),
|
||||
("REVPREC", "", True),
|
||||
],
|
||||
)
|
||||
async def test_binary_guardrail_semantics(task: str, content: str, allowed: bool, monkeypatch):
|
||||
monkeypatch.setenv("USE_MOCK_LLM", "false")
|
||||
out = await classify_with_framework_llm(
|
||||
FakeLLM(content), task, {"text": "texto", "context": {}}, profile_name="guardrail"
|
||||
)
|
||||
assert bool(out["allowed"]) is allowed
|
||||
|
||||
|
||||
def test_binary_polarity_contract():
|
||||
assert _BINARY_BLOCK_DIGIT["REVPREC"] == "1"
|
||||
assert _BINARY_BLOCK_DIGIT.get("PINJ", "0") == "0"
|
||||
assert "PINJ" in _BINARY_TASKS and "REVPREC" in _BINARY_TASKS
|
||||
|
||||
|
||||
def test_pinj_prompt_is_binary_and_keeps_examples():
|
||||
prompt = build_pinj_prompt("texto qualquer")
|
||||
assert "Responda APENAS um caractere" in prompt
|
||||
assert '"allowed"' not in prompt
|
||||
assert prompt.count(" Texto: ") == 17
|
||||
assert prompt.count(" Saída: 0") == 11
|
||||
assert prompt.count(" Saída: 1") == 6
|
||||
|
||||
|
||||
def test_revprec_prompt_preserves_boundaries():
|
||||
prompt = build_revprec_prompt("FALA_NOVA", "\nHistorico da conversa:\n[assistant] CANCELEI_ANTES\n")
|
||||
assert "APENAS 1 ou 0" in prompt
|
||||
assert 'somente a fala do bloco "Resposta:"' in prompt
|
||||
assert "PROTOCOLO" in prompt
|
||||
assert "DESCRIÇÃO DA FATURA" in prompt
|
||||
18
tests/migration/test_guardrail_original_defaults_parity.py
Normal file
18
tests/migration/test_guardrail_original_defaults_parity.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
from agent_framework.guardrails.config_loader import load_guardrails_config
|
||||
|
||||
|
||||
def test_original_default_guardrails_estao_ativos_no_pipeline_framework():
|
||||
cfg=yaml.safe_load(Path('config/guardrails.yaml').read_text())
|
||||
inputs={x['code'] for x in cfg['input'] if x.get('enabled')}
|
||||
outputs={x['code'] for x in cfg['output'] if x.get('enabled')}
|
||||
assert {'MSK','INPUT_SIZE','PINJ','COER'} <= inputs
|
||||
assert {'OOS','AOFERTA','REVPREC','FRASEOLOGIA','CMP'} <= outputs
|
||||
|
||||
|
||||
def test_framework_loader_instancia_coer_e_fraseologia():
|
||||
loaded=load_guardrails_config('config/guardrails.yaml')
|
||||
codes={getattr(x,'code','') for rails in (loaded.input_rails, loaded.output_rails, loaded.retrieval_rails, loaded.tool_rails) for x in rails}
|
||||
assert 'COER' in codes
|
||||
assert 'FRASEOLOGIA' in codes
|
||||
198
tests/migration/test_invoice_context_framework_native.py
Normal file
198
tests/migration/test_invoice_context_framework_native.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.cache.cache import InMemoryCache
|
||||
from app.domain.contas.invoice_context import InvoiceContextService
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.complete_calls = 0
|
||||
self.billing_calls = 0
|
||||
self.pdf_calls = 0
|
||||
|
||||
def consultar_faturas(self, msisdn):
|
||||
self.complete_calls += 1
|
||||
return {
|
||||
"billingProfile": {"customer": {"customerId": "C1"}},
|
||||
"paymentItems": [{"invoiceId": "I1"}],
|
||||
}
|
||||
|
||||
def billing_analysis(self, msisdn, **kwargs):
|
||||
self.billing_calls += 1
|
||||
return {"invoiceExplanation": "explicacao"}
|
||||
|
||||
def bill_pdf(self, msisdn, invoice_id, customer_id, **kwargs):
|
||||
self.pdf_calls += 1
|
||||
return {"119": {"SVA Detalhe Total": [{"desc": "Tamboro"}]}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_reusa_cache_na_mesma_sessao():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
a = await svc.get(session_id="s1", msisdn="119", include_detail=True)
|
||||
b = await svc.get(session_id="s1", msisdn="119", include_detail=True)
|
||||
assert a.cache_hit is False
|
||||
assert b.cache_hit is True
|
||||
assert client.complete_calls == 1
|
||||
assert client.billing_calls == 1
|
||||
assert client.pdf_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_isola_cache_por_sessao():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
await svc.get(session_id="s1", msisdn="119")
|
||||
await svc.get(session_id="s2", msisdn="119")
|
||||
assert client.complete_calls == 2
|
||||
assert client.billing_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_sem_session_nao_compartilha_cache_global():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
await svc.get(session_id="", msisdn="119")
|
||||
await svc.get(session_id="", msisdn="119")
|
||||
assert client.complete_calls == 2
|
||||
assert client.billing_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_use_cache_false_forca_nova_busca():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
await svc.get(session_id="s1", msisdn="119")
|
||||
await svc.get(session_id="s1", msisdn="119", use_cache=False)
|
||||
assert client.complete_calls == 2
|
||||
assert client.billing_calls == 2
|
||||
|
||||
|
||||
def test_invoice_explanation_reusa_evidencia_prefetch_sem_nova_api():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class C(Client):
|
||||
def consultar_faturas(self, msisdn):
|
||||
raise AssertionError("nao deveria consultar novamente")
|
||||
def billing_analysis(self, msisdn, **kwargs):
|
||||
raise AssertionError("nao deveria consultar novamente")
|
||||
|
||||
service = ContasDomainService(client=C()) # type: ignore[arg-type]
|
||||
result = service.invoice_explanation(
|
||||
msisdn="119",
|
||||
complete_invoices_payload={"paymentItems": []},
|
||||
billing_analysis={"invoiceExplanation": "cache"},
|
||||
)
|
||||
assert result["billing_analysis"]["invoiceExplanation"] == "cache"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_singleflight_mesma_sessao_nao_duplica_chamadas():
|
||||
import asyncio
|
||||
|
||||
class Blocking(Client):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.release = asyncio.Event()
|
||||
|
||||
def consultar_faturas(self, msisdn):
|
||||
import time
|
||||
self.complete_calls += 1
|
||||
time.sleep(0.08)
|
||||
return {
|
||||
"billingProfile": {"customer": {"customerId": "C1"}},
|
||||
"paymentItems": [{"invoiceId": "I1"}],
|
||||
}
|
||||
|
||||
def billing_analysis(self, msisdn, **kwargs):
|
||||
import time
|
||||
self.billing_calls += 1
|
||||
time.sleep(0.08)
|
||||
return {"invoiceExplanation": "explicacao"}
|
||||
|
||||
client = Blocking()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
a, b = await asyncio.gather(
|
||||
svc.get(session_id="s1", msisdn="119", message_id="m1"),
|
||||
svc.get(session_id="s1", msisdn="119", message_id="m1"),
|
||||
)
|
||||
assert client.complete_calls == 1
|
||||
assert client.billing_calls == 1
|
||||
assert a.complete_invoices == b.complete_invoices
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_prefetch_emite_cvn_uma_vez_por_sessao():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
first = await svc.get(session_id="s1", msisdn="119", message_id="msg-1", use_cache=False)
|
||||
second = await svc.get(session_id="s1", msisdn="119", message_id="msg-2", use_cache=False)
|
||||
assert [x["code"] for x in first.business_events or []] == ["CVN.002", "CVN.006"]
|
||||
assert second.business_events == []
|
||||
assert first.business_events[0]["payload"]["messageId"] == "msg-1"
|
||||
assert first.business_events[1]["payload"]["agentSpecificData"] == '{"billingId": "I1"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_nova_sessao_pode_emitir_cvn_novamente():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
first = await svc.get(session_id="s1", msisdn="119", use_cache=False)
|
||||
second = await svc.get(session_id="s2", msisdn="119", use_cache=False)
|
||||
assert [x["code"] for x in first.business_events or []] == ["CVN.002", "CVN.006"]
|
||||
assert [x["code"] for x in second.business_events or []] == ["CVN.002", "CVN.006"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_billing_analysis_falha_emite_cvn007_sem_perder_complete_invoices():
|
||||
class Failing(Client):
|
||||
def billing_analysis(self, msisdn, **kwargs):
|
||||
self.billing_calls += 1
|
||||
raise RuntimeError("HTTP 550")
|
||||
|
||||
client = Failing()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
ctx = await svc.get(session_id="s1", msisdn="119", message_id="msg-fail", use_cache=False)
|
||||
assert ctx.complete_invoices is not None
|
||||
assert ctx.billing_analysis is None
|
||||
assert "billing_analysis" in str(ctx.error)
|
||||
assert [x["code"] for x in ctx.business_events or []] == ["CVN.002", "CVN.007"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_cache_incompleto_busca_detail_quando_solicitado_depois():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
await svc.get(session_id="s1", msisdn="119", include_detail=False)
|
||||
detailed = await svc.get(session_id="s1", msisdn="119", include_detail=True)
|
||||
assert detailed.invoice_detail is not None
|
||||
assert client.pdf_calls == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_expoe_timings_e_erros_por_subconsulta():
|
||||
class Partial(Client):
|
||||
def billing_analysis(self, msisdn, **kwargs):
|
||||
self.billing_calls += 1
|
||||
raise RuntimeError("indisponivel")
|
||||
|
||||
client = Partial()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
ctx = await svc.get(session_id="s1", msisdn="119", include_detail=True, use_cache=False)
|
||||
assert ctx.metadata["cache_hit"] is False
|
||||
assert ctx.metadata["fetch_elapsed_ms"] >= 0
|
||||
assert {"complete_invoices", "billing_analysis", "invoice_detail"} <= set(ctx.metadata["task_timings"])
|
||||
assert "billing_analysis" in ctx.errors
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoice_context_cache_hit_expoe_cache_age_sem_republicar_eventos():
|
||||
client = Client()
|
||||
svc = InvoiceContextService(client, InMemoryCache(), ttl_seconds=60) # type: ignore[arg-type]
|
||||
await svc.get(session_id="s1", msisdn="119")
|
||||
cached = await svc.get(session_id="s1", msisdn="119")
|
||||
assert cached.cache_hit is True
|
||||
assert cached.metadata["cache_hit"] is True
|
||||
assert cached.metadata["cache_age_ms"] >= 0
|
||||
assert cached.business_events == []
|
||||
17
tests/migration/test_no_legacy_dependency.py
Normal file
17
tests/migration/test_no_legacy_dependency.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCANNED = [ROOT / "app", ROOT / "mcp", ROOT / "config"]
|
||||
FORBIDDEN = "agente_" + "contas_tim"
|
||||
|
||||
|
||||
def test_no_legacy_imports_or_references():
|
||||
violations = []
|
||||
for base in SCANNED:
|
||||
for path in base.rglob("*"):
|
||||
if not path.is_file() or path.suffix not in {".py", ".yaml", ".yml"}:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if FORBIDDEN in text:
|
||||
violations.append(str(path.relative_to(ROOT)))
|
||||
assert not violations, f"Dependências do pacote anterior encontradas: {violations}"
|
||||
127
tests/migration/test_original_compliance_anatel.py
Normal file
127
tests/migration/test_original_compliance_anatel.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("USE_MOCK_LLM", "true")
|
||||
|
||||
from agent_framework.guardrails.calibrated import apply_output_rails, compliance_anatel
|
||||
from agent_framework.guardrails.calibrated._compat import RailResult
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Testes unitários diretos em compliance_anatel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_fluxo_nao_ajuste_passa_sem_verificar():
|
||||
"""Quando o fluxo não exige protocolo, o rail não deve bloquear."""
|
||||
r = compliance_anatel("Sua fatura está disponível.", context={})
|
||||
assert isinstance(r, RailResult)
|
||||
assert r.allowed is True
|
||||
assert r.code == "CMP"
|
||||
assert r.mechanism == "regex"
|
||||
|
||||
|
||||
def test_fluxo_ajuste_com_protocolo_passa():
|
||||
r = compliance_anatel(
|
||||
"O ajuste foi realizado. Protocolo: 123456.",
|
||||
context={"tipo_fluxo": "ajuste"},
|
||||
)
|
||||
assert r.allowed is True
|
||||
assert r.code == "CMP"
|
||||
|
||||
|
||||
def test_fluxo_ajuste_sem_protocolo_bloqueia():
|
||||
r = compliance_anatel(
|
||||
"O ajuste foi realizado com sucesso.",
|
||||
context={"tipo_fluxo": "ajuste"},
|
||||
)
|
||||
assert r.allowed is False
|
||||
assert r.code == "CMP"
|
||||
assert "protocolo" in r.reason.lower()
|
||||
|
||||
|
||||
def test_requer_protocolo_true_com_protocolo_passa():
|
||||
r = compliance_anatel(
|
||||
"Cancelamento efetuado. Protocolo: 9876543.",
|
||||
context={"requer_protocolo": True},
|
||||
)
|
||||
assert r.allowed is True
|
||||
|
||||
|
||||
def test_requer_protocolo_true_sem_protocolo_bloqueia():
|
||||
r = compliance_anatel(
|
||||
"Cancelamento efetuado com sucesso.",
|
||||
context={"requer_protocolo": True},
|
||||
)
|
||||
assert r.allowed is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"Protocolo: 123456",
|
||||
"protocolo - 9876543",
|
||||
"PROTOCOLO: 1000001",
|
||||
"Seu protocolo é 200001.",
|
||||
"O número de protocolo: 654321 foi registrado.",
|
||||
])
|
||||
def test_formatos_de_protocolo_aceitos(text):
|
||||
r = compliance_anatel(text, context={"tipo_fluxo": "ajuste"})
|
||||
assert r.allowed is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"O ajuste foi feito.",
|
||||
"Tudo certo com sua solicitação.",
|
||||
"Protocolo: 12345", # apenas 5 dígitos — insuficiente
|
||||
"protocolo ABC123", # não começa com dígitos
|
||||
])
|
||||
def test_formatos_invalidos_ou_ausentes_bloqueiam(text):
|
||||
r = compliance_anatel(text, context={"tipo_fluxo": "ajuste"})
|
||||
assert r.allowed is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Testes de integração via apply_output_rails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_pipeline_ajuste_sem_protocolo_nao_bloqueia_cmp():
|
||||
d = apply_output_rails(
|
||||
text="O ajuste foi realizado com sucesso.",
|
||||
user_text="O ajuste foi realizado com sucesso.",
|
||||
tool_calls=None,
|
||||
context={"tipo_fluxo": "ajuste"},
|
||||
)
|
||||
assert d.allowed is True
|
||||
assert d.code is None
|
||||
assert all(r.code != "CMP" for r in d.results)
|
||||
|
||||
|
||||
def test_pipeline_ajuste_com_protocolo_passa():
|
||||
d = apply_output_rails(
|
||||
text="Ajuste concluído. Protocolo: 123456.",
|
||||
user_text="Ajuste concluído. Protocolo: 123456.",
|
||||
tool_calls=None,
|
||||
context={"tipo_fluxo": "ajuste"},
|
||||
)
|
||||
assert d.allowed is True
|
||||
|
||||
|
||||
def test_pipeline_sem_context_nao_exige_protocolo():
|
||||
"""Sem context, CMP não deve bloquear — fluxo não é de ajuste."""
|
||||
d = apply_output_rails(
|
||||
text="O ajuste foi realizado.",
|
||||
user_text="O ajuste foi realizado.",
|
||||
tool_calls=None,
|
||||
)
|
||||
assert d.allowed is True
|
||||
assert all(r.code != "CMP" for r in d.results)
|
||||
|
||||
|
||||
def test_pipeline_cmp_nao_entra_nos_results():
|
||||
"""CMP roda no fallback determinístico do agente, fora do output rail."""
|
||||
d = apply_output_rails(
|
||||
text="Posso te ajudar com mais alguma coisa?",
|
||||
user_text="Posso te ajudar com mais alguma coisa?",
|
||||
tool_calls=None,
|
||||
context={"tipo_fluxo": "consulta"},
|
||||
)
|
||||
assert all(r.code != "CMP" for r in d.results)
|
||||
448
tests/migration/test_original_contestation_validation.py
Normal file
448
tests/migration/test_original_contestation_validation.py
Normal file
@@ -0,0 +1,448 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.guardrails.calibrated import contestation_validation
|
||||
from agent_framework.guardrails.calibrated.contestation_validation import (
|
||||
validate_contestation_items,
|
||||
)
|
||||
|
||||
|
||||
def _vas_invoice(*items: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"Servicos de Valor Adicionado": list(items)}
|
||||
|
||||
|
||||
def test_validate_contestation_items_bloqueia_item_inexistente() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "Pacote nao cobrado",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
}
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
_vas_invoice({"desc": "Pacote cobrado", "valor": "10,00"}),
|
||||
)
|
||||
|
||||
assert error == "Item 'Pacote nao cobrado' nao encontrado no json da fatura."
|
||||
assert validation_log[-1]["erro"] == "item_nao_encontrado_na_fatura"
|
||||
|
||||
|
||||
def test_validate_contestation_items_bloqueia_item_fora_da_secao_vas() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "Pacote Dados",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
}
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
{"Plano": [{"desc": "Pacote Dados", "valor": "10,00"}]},
|
||||
)
|
||||
|
||||
assert error == "Item 'Pacote Dados' nao e do tipo VAS no json da fatura."
|
||||
assert validation_log[-1]["erro"] == "item_fora_secao_vas"
|
||||
|
||||
|
||||
def test_validate_contestation_items_bloqueia_vas_estrategico() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "Netflix Premium",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
}
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
_vas_invoice({"desc": "Netflix Premium", "valor": "10,00"}),
|
||||
)
|
||||
|
||||
assert (
|
||||
error
|
||||
== "Item 'Netflix Premium' identificado como VAS estrategico e nao pode ser ajustado."
|
||||
)
|
||||
assert validation_log[-1]["erro"] == "vas_estrategico_nao_permitido"
|
||||
|
||||
|
||||
def test_validate_contestation_items_bloqueia_valor_maior_que_o_item() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "Pacote Musica",
|
||||
"claimed_amount": "12",
|
||||
"validated_amount": "12",
|
||||
}
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
_vas_invoice({"desc": "Pacote Musica", "valor": "10,00"}),
|
||||
)
|
||||
|
||||
assert (
|
||||
error
|
||||
== "Valor de ajuste do item 'Pacote Musica' excede o valor cobrado na fatura."
|
||||
)
|
||||
assert validation_log[-1]["erro"] == "valor_ajuste_maior_que_item"
|
||||
|
||||
|
||||
def _record_observation_factory(records: list[dict[str, Any]]):
|
||||
@contextmanager
|
||||
def _record_observation(
|
||||
*,
|
||||
name: str,
|
||||
input: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
):
|
||||
record: dict[str, Any] = {
|
||||
"name": name,
|
||||
"input": input,
|
||||
"metadata": metadata,
|
||||
"updates": [],
|
||||
}
|
||||
|
||||
class _Observation:
|
||||
def update(self, **kwargs: Any) -> None:
|
||||
record["updates"].append(dict(kwargs))
|
||||
|
||||
records.append(record)
|
||||
yield _Observation()
|
||||
|
||||
return _record_observation
|
||||
|
||||
|
||||
def test_validate_contestation_items_emite_spans_quando_bloqueia(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
records: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
contestation_validation,
|
||||
"_start_guardrail_observation",
|
||||
_record_observation_factory(records),
|
||||
)
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
[
|
||||
{
|
||||
"item_name": "Pacote nao cobrado",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
}
|
||||
],
|
||||
_vas_invoice({"desc": "Pacote cobrado", "valor": "10,00"}),
|
||||
)
|
||||
|
||||
assert error
|
||||
assert [r["name"] for r in records] == [
|
||||
"guardrail.CVAL.evaluated",
|
||||
"guardrail.CVAL.blocked",
|
||||
]
|
||||
|
||||
parent, child = records
|
||||
assert parent["metadata"] == {
|
||||
"mechanism": "guardrail_action_validation",
|
||||
"code": "CVAL",
|
||||
"action": "abrir_contestacao_cliente",
|
||||
}
|
||||
assert parent["input"]["items_count"] == 1
|
||||
assert parent["input"]["invoice_candidates_count"] == 1
|
||||
assert parent["updates"][-1]["level"] == "WARNING"
|
||||
assert parent["updates"][-1]["output"]["approved"] is False
|
||||
assert parent["updates"][-1]["output"]["validation_log"] == validation_log
|
||||
assert (
|
||||
parent["updates"][-1]["output"]["reason"]
|
||||
== "item_nao_encontrado_na_fatura"
|
||||
)
|
||||
|
||||
assert child["metadata"]["reason"] == "item_nao_encontrado_na_fatura"
|
||||
assert child["updates"][-1]["level"] == "WARNING"
|
||||
assert child["updates"][-1]["output"]["blocked"] is True
|
||||
|
||||
|
||||
def test_validate_contestation_items_emite_span_quando_aprova(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
records: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
contestation_validation,
|
||||
"_start_guardrail_observation",
|
||||
_record_observation_factory(records),
|
||||
)
|
||||
|
||||
items, validation_log, error = validate_contestation_items(
|
||||
[
|
||||
{
|
||||
"item_name": "Pacote Musica",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
}
|
||||
],
|
||||
_vas_invoice({"desc": "Pacote Musica", "valor": "10,00"}),
|
||||
)
|
||||
|
||||
assert error is None
|
||||
assert validation_log[-1]["status"] == "aprovado"
|
||||
assert items[0]["validated_amount"] == "10"
|
||||
assert [r["name"] for r in records] == ["guardrail.CVAL.evaluated"]
|
||||
|
||||
update = records[0]["updates"][-1]
|
||||
assert "level" not in update
|
||||
assert update["output"]["approved"] is True
|
||||
assert update["output"]["items_count"] == 1
|
||||
assert update["output"]["items_validated_count"] == 1
|
||||
assert update["output"]["items_approved_count"] == 1
|
||||
assert update["output"]["items_rejected_count"] == 0
|
||||
assert update["output"]["validation_log"] == validation_log
|
||||
|
||||
|
||||
def test_validate_contestation_items_valida_todos_antes_de_retornar() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "Pacote Musica",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
},
|
||||
{
|
||||
"item_name": "Pacote nao cobrado",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
},
|
||||
{
|
||||
"item_name": "Pacote Filme",
|
||||
"claimed_amount": "5",
|
||||
"validated_amount": "5",
|
||||
},
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
_vas_invoice(
|
||||
{"desc": "Pacote Musica", "valor": "10,00"},
|
||||
{"desc": "Pacote Filme", "valor": "5,00"},
|
||||
),
|
||||
)
|
||||
|
||||
assert error == "Item 'Pacote nao cobrado' nao encontrado no json da fatura."
|
||||
statuses = [(entry["item_name"], entry["status"]) for entry in validation_log]
|
||||
assert statuses == [
|
||||
("Pacote Musica", "aprovado"),
|
||||
("Pacote nao cobrado", "reprovado"),
|
||||
("Pacote Filme", "aprovado"),
|
||||
]
|
||||
|
||||
|
||||
def test_validate_contestation_items_usa_classe_avulso_canonica() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "Tamboro Mensal",
|
||||
"claimed_amount": "14.99",
|
||||
"validated_amount": "14.99",
|
||||
}
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
{
|
||||
"Secao Qualquer": [
|
||||
{
|
||||
"desc": "Tamboro Mensal",
|
||||
"value": 14.99,
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert error is None
|
||||
assert validation_log[-1]["status"] == "aprovado"
|
||||
assert validation_log[-1]["secao_vas"] is True
|
||||
|
||||
|
||||
def test_validate_contestation_items_bloqueia_classe_estrategico_canonica() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "ServicoNovo",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
}
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
{
|
||||
"Servicos de Valor Adicionado": [
|
||||
{
|
||||
"desc": "ServicoNovo",
|
||||
"value": 10.0,
|
||||
"classe": "estrategico",
|
||||
"estrategico": True,
|
||||
"verb": "falar sobre",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert (
|
||||
error
|
||||
== "Item 'ServicoNovo' identificado como VAS estrategico e nao pode ser ajustado."
|
||||
)
|
||||
assert validation_log[-1]["erro"] == "vas_estrategico_nao_permitido"
|
||||
assert validation_log[-1]["vas_estrategico"] is True
|
||||
|
||||
|
||||
def test_validate_contestation_items_span_blocked_inclui_contagens(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
records: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
contestation_validation,
|
||||
"_start_guardrail_observation",
|
||||
_record_observation_factory(records),
|
||||
)
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
[
|
||||
{
|
||||
"item_name": "Pacote Musica",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
},
|
||||
{
|
||||
"item_name": "Pacote nao cobrado",
|
||||
"claimed_amount": "10",
|
||||
"validated_amount": "10",
|
||||
},
|
||||
],
|
||||
_vas_invoice({"desc": "Pacote Musica", "valor": "10,00"}),
|
||||
)
|
||||
|
||||
assert error
|
||||
assert [r["name"] for r in records] == [
|
||||
"guardrail.CVAL.evaluated",
|
||||
"guardrail.CVAL.blocked",
|
||||
]
|
||||
parent, child = records
|
||||
parent_output = parent["updates"][-1]["output"]
|
||||
assert parent_output["items_validated_count"] == 2
|
||||
assert parent_output["items_approved_count"] == 1
|
||||
assert parent_output["items_rejected_count"] == 1
|
||||
assert parent_output["validation_log"] == validation_log
|
||||
|
||||
child_output = child["updates"][-1]["output"]
|
||||
assert child_output["items_validated_count"] == 2
|
||||
assert child_output["items_approved_count"] == 1
|
||||
assert child_output["items_rejected_count"] == 1
|
||||
|
||||
|
||||
def test_validate_contestation_items_bloqueia_quando_total_ajustes_excede_fatura() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "VAS Musica",
|
||||
"claimed_amount": "100",
|
||||
"validated_amount": "100",
|
||||
},
|
||||
{
|
||||
"item_name": "VAS Filme",
|
||||
"claimed_amount": "100",
|
||||
"validated_amount": "100",
|
||||
},
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
{
|
||||
"Fatura Resumo": [{"desc": "Total geral", "value": 150.0}],
|
||||
"Servicos de Valor Adicionado": [
|
||||
{"desc": "VAS Musica", "valor": "100,00"},
|
||||
{"desc": "VAS Filme", "valor": "100,00"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert error == (
|
||||
"Valor total de ajustes (200) excede o valor total da fatura (150)."
|
||||
)
|
||||
total_entry = validation_log[-1]
|
||||
assert total_entry["erro"] == "total_ajustes_excede_fatura"
|
||||
assert total_entry["valor_total_ajustes"] == "200"
|
||||
assert total_entry["valor_total_fatura"] == "150"
|
||||
|
||||
|
||||
def test_validate_contestation_items_aprova_quando_total_ajustes_dentro_do_limite() -> None:
|
||||
items = [
|
||||
{
|
||||
"item_name": "VAS Musica",
|
||||
"claimed_amount": "50",
|
||||
"validated_amount": "50",
|
||||
}
|
||||
]
|
||||
|
||||
_, validation_log, error = validate_contestation_items(
|
||||
items,
|
||||
{
|
||||
"Fatura Resumo": [{"desc": "Total geral", "value": 150.0}],
|
||||
"Servicos de Valor Adicionado": [
|
||||
{"desc": "VAS Musica", "valor": "50,00"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert error is None
|
||||
assert validation_log[-1]["status"] == "aprovado"
|
||||
|
||||
|
||||
def test_contestation_action_classifies_already_contested_and_missing_requested_item():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Client:
|
||||
def billing_analysis(self, msisdn):
|
||||
return {"currentInvoice": {"items": [{"description": "TIM Fashion Mensal", "value": 14.99}, {"description": "Outro VAS", "value": 5.0}]}}
|
||||
def contestar(self, payload):
|
||||
return {"itemsResponse": [
|
||||
{"itemName": "TIM Fashion Mensal", "message": "Item ja foi contestado anteriormente", "status": "REJEITADA"}
|
||||
]}
|
||||
|
||||
action = build_contas_workflow_actions(ContasDomainService(client=Client())).get("abrir_contestacao_cliente")
|
||||
result = action({
|
||||
"msisdn": "11999999999", "skip_invoice_item_validation": True,
|
||||
"items": [
|
||||
{"itemName": "TIM Fashion Mensal", "claimedAmount": "14,99", "validatedAmount": "14,99"},
|
||||
{"itemName": "Outro VAS", "claimedAmount": "5,00", "validatedAmount": "5,00"},
|
||||
],
|
||||
}, {"input": {}})
|
||||
assert result["itens_ja_contestados"] == ["TIM Fashion Mensal"]
|
||||
assert result["contested_items"] == []
|
||||
assert result["not_contested_items"][0]["itemName"] == "Outro VAS"
|
||||
assert result["contested_invoice_amount_open"] == "0.00"
|
||||
|
||||
|
||||
def test_contestation_action_calculates_totals_only_for_items_actually_contested():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
class Client:
|
||||
def contestar(self, payload):
|
||||
return {"itemsResponse": [
|
||||
{"itemName": "TIM Fashion Mensal", "status": "INICIADA"},
|
||||
{"itemName": "Outro VAS", "status": "REJEITADA", "message": "fora da alcada"},
|
||||
]}
|
||||
|
||||
action = build_contas_workflow_actions(ContasDomainService(client=Client())).get("abrir_contestacao_cliente")
|
||||
result = action({
|
||||
"msisdn": "11999999999", "skip_invoice_item_validation": True,
|
||||
"items": [
|
||||
{"itemName": "TIM Fashion Mensal", "claimedAmount": "14,99", "validatedAmount": "14,99"},
|
||||
{"itemName": "Outro VAS", "claimedAmount": "5,00", "validatedAmount": "5,00"},
|
||||
],
|
||||
}, {"input": {}})
|
||||
assert [x["itemName"] for x in result["contested_items"]] == ["TIM Fashion Mensal"]
|
||||
assert [x["itemName"] for x in result["not_contested_items"]] == ["Outro VAS"]
|
||||
assert result["contested_invoice_amount_open"] == "14.99"
|
||||
assert result["contested_invoice_amount"] == "14.99"
|
||||
175
tests/migration/test_original_item_matcher_transcription.py
Normal file
175
tests/migration/test_original_item_matcher_transcription.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Caracterização do matcher com erros de transcrição sobre o catálogo VAS real
|
||||
(embutido em ``_CATALOG_CSV`` abaixo).
|
||||
|
||||
Mostra como o ranking de similaridade (frase/token/som) se sai HOJE: para uma
|
||||
menção com erro de grafia/pronúncia, o topo do bloco de candidatos renderizado
|
||||
deve ser uma variante da família esperada, competindo contra o catálogo inteiro
|
||||
(~190 variantes). Casos sabidamente não cobertos são ``xfail`` com o motivo —
|
||||
documentam limitações atuais, não bugs a esconder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.contas.item_matcher import SimilarityItemMatcher
|
||||
|
||||
# Catálogo VAS real (nome_base,n_variantes,variantes) — antes a fixture
|
||||
# data/termos_complexos_3.csv. Mantido como CSV literal: pra atualizar, basta
|
||||
# colar/editar linhas. A mojibake ("Padrão") é do dump original e é inócua aqui
|
||||
# (a normalização tira acento; o que importa é casar a família, não o sufixo).
|
||||
_CATALOG_CSV = """\
|
||||
nome_base,n_variantes,variantes
|
||||
Fluid,18,"Fluid Mensal, Fluid Mensal 10, Fluid Mensal 11, Fluid Mensal 12, Fluid Mensal 13, Fluid Mensal 2, Fluid Mensal 3, Fluid Mensal 4, Fluid Mensal 5, Fluid Mensal 6, Fluid Mensal 7, Fluid Mensal 8, Fluid Mensal 9, Fluid Semanal, Fluid Semanal 2, Fluid Semanal 3, Fluid Semanal 4, Fluid Semanal 5"
|
||||
TapLingo,13,"TapLingo 1 Idioma Diario, TapLingo 1 Idioma Mensal, TapLingo 1 Idioma Semanal, TapLingo 1 Idioma Semanal 1, TapLingo 1 Idioma Semanal 2, TapLingo 2 Idiomas Mensal, TapLingo 2 Idiomas Semanal, TapLingo 2 Idiomas Semanal 1, TapLingo 2 Idiomas Semanal 2, TapLingo Idiomas Mensal, TapLingo Idiomas Semanal, TapLingo Idiomas Semanal 1, TapLingo Idiomas Semanal 2"
|
||||
MasterChef,9,"MasterChef Mensal, MasterChef Mensal 1, MasterChef Mensal 2, MasterChef Semanal, MasterChef Semanal 1, MasterChef Semanal 2, MasterChef Semanal 3, MasterChef Semanal 4, MasterChef Semanal 5"
|
||||
Gameloft,7,"Gameloft mensal 1, Gameloft mensal 2, Gameloft mensal 3, Gameloft semanal 1, Gameloft semanal 2, Gameloft semanal 3, Gameloft semanal 4"
|
||||
Prime Tube,7,"Prime Tube - Diario, Prime Tube - Mes 1, Prime Tube - Semanal 1, Prime Tube - Semanal 2, Prime Tube - Semanal 3, Prime Tube - avulso 1, Prime Tube - avulso 2"
|
||||
Tamboro,7,"Tamboro Mensal, Tamboro Mensal 1, Tamboro Semanal, Tamboro Semanal 1, Tamboro Semanal 2, Tamboro Semanal 3, Tamboro Semanal 4"
|
||||
Kids Clube,6,"Kids Clube Mes, Kids Clube Mes1, Kids Clube Semana, Kids Clube Semana1, Kids Clube Semana2, Kids Clube Semana3"
|
||||
Playkids,6,"Playkids - Mensal Promo, Playkids - Semanal, Playkids - Semanal 1, Playkids - Semanal 2, Playkids - Semanal 3, Playkids - Semanal 4"
|
||||
Dog Lovers,5,"Dog Lovers Mes 1, Dog Lovers Semanal 1, Dog Lovers Semanal 2, Dog Lovers Semanal 3, Dog Lovers Semanal 4"
|
||||
Fit Me APP,5,"Fit Me APP Premium Mensal, Fit Me APP Premium Semanal, Fit Me APP Premium Semanal 1, Fit Me APP Premium Semanal 2, Fit Me APP Premium Semanal 3"
|
||||
Fuze Forge,5,"Fuze Forge Mensal, Fuze Forge Semanal, Fuze Forge Semanal 1, Fuze Forge Semanal 2, Fuze Forge Semanal 3"
|
||||
LiveFC,5,"LiveFC Mensal, LiveFC Semanal 1, LiveFC Semanal 2, LiveFC Semanal 3, LiveFC Semanal 4"
|
||||
Woman Fitness,5,"Woman Fitness Mes 1, Woman Fitness Semanal 1, Woman Fitness Semanal 2, Woman Fitness Semanal 3, Woman Fitness Semanal 4"
|
||||
Audiobooks UBOOK,4,"Audiobooks UBOOK Light Mes, Audiobooks UBOOK Mes, Audiobooks UBOOK Platinum Mes, Audiobooks UBOOK Premium Mes"
|
||||
Disney+,4,"Disney+ Padrão, Disney+ Premium, Upgrade Disney+ Padrao, Upgrade Disney+ Premium"
|
||||
Pocoyo,4,"Pocoyo Mensal, Pocoyo Mensal 1, Pocoyo Semanal 3, Pocoyo Semanal 4"
|
||||
Superclipe,4,"Superclipe Mensal, Superclipe Semanal, Superclipe Semanal 1, Superclipe Semanal 2"
|
||||
Truecaller,4,"Truecaller Semanal, Truecaller Semanal 1, Truecaller Semanal 2, Truecaller Semanal 3"
|
||||
Globoplay,3,"Globoplay Padrão com anúncio, Globoplay Premium, Upgrade Globoplay Premium"
|
||||
HBO Max,3,"HBO Max Basico com Anuncios, HBO Max Platinum, Upgrade HBO Max Standard"
|
||||
Netflix,3,"Netflix Padrão, Netflix Padrão Anúncios, Netflix Premium"
|
||||
TIM Kids,3,"TIM Kids - Semanal - 2, TIM Kids - Semanal - 3, TIM Kids - Semanal - 4"
|
||||
+ MOB,2,"+ MOB - Semanal 1, + MOB - Semanal 2"
|
||||
Chat Lovme,2,"Chat Lovme - Semanal 1, Chat Lovme - Semanal 2"
|
||||
Clube Gameloft,2,"Clube Gameloft mensal, Clube Gameloft semanal"
|
||||
Deezer,2,"Deezer PROMO SVA diario, Upgrade Deezer Premium"
|
||||
MVR360,2,"MVR360 - Mes 1, MVR360 - Semanal 4"
|
||||
MVR Joker,2,"MVR Joker Mensal, MVR Joker Semanal"
|
||||
Star,2,"Star Mensal, Star Promocional"
|
||||
Apple Music,1,Apple Music SVA Mes
|
||||
Babbel,1,Babbel Mes
|
||||
Dindo HIT,1,Dindo HIT - Mes 1
|
||||
Espn,1,Espn mensal
|
||||
HBO Max Ads,1,Upgrade HBO Max Ads-Platinum
|
||||
Instarena,1,Instarena mensal
|
||||
Jokoo,1,Jokoo Mes 1
|
||||
Kwiz,1,Kwiz - Mes 1
|
||||
Loja Gameloft,1,Loja Gameloft SVA mensal
|
||||
Max Mobile,1,Max Mobile Mensal
|
||||
MobiVideos,1,MobiVideos - Mes 1
|
||||
Mundome,1,Mundome Mensal
|
||||
Nordeste FC,1,Nordeste FC
|
||||
Paramount+,1,Paramount+ Mensal
|
||||
Premiere GB,1,Premiere GB
|
||||
TIM Games Club,1,TIM Games Club
|
||||
TIM Games for Kids,1,TIM Games for Kids
|
||||
TIM Music,1,TIM Music Upsell SVA Semanal
|
||||
Zodiack,1,Zodiack - Mes 1
|
||||
"""
|
||||
|
||||
|
||||
def _load_catalog() -> dict[str, str]:
|
||||
"""variante -> nome_base, a partir do catálogo embutido (``_CATALOG_CSV``)."""
|
||||
variant_base: dict[str, str] = {}
|
||||
for row in csv.DictReader(io.StringIO(_CATALOG_CSV)):
|
||||
for variant in (v.strip() for v in row["variantes"].split(",")):
|
||||
variant_base[variant] = row["nome_base"]
|
||||
return variant_base
|
||||
|
||||
|
||||
_VARIANT_BASE = _load_catalog()
|
||||
_UNIVERSE = list(_VARIANT_BASE)
|
||||
|
||||
|
||||
class _Resp:
|
||||
content = '{"matches": []}'
|
||||
tool_calls: list[Any] = []
|
||||
additional_kwargs: dict[str, Any] = {"reasoning_content": "ok"}
|
||||
|
||||
|
||||
class _CapturingLLM:
|
||||
"""Não decide nada; só captura o prompt renderizado por ``match`` p/ inspeção."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last_prompt = ""
|
||||
|
||||
def invoke(self, messages: list[Any], config: dict[str, Any] | None = None) -> _Resp:
|
||||
self.last_prompt = str(getattr(messages[-1], "content", ""))
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _rendered_descs(mention: str) -> list[str]:
|
||||
"""Top-K determinístico do matcher migrado contra o mesmo catálogo real."""
|
||||
return [name for name, _score in SimilarityItemMatcher(top_k=10).ranked(mention, _UNIVERSE)]
|
||||
|
||||
|
||||
def _top_base(mention: str) -> str:
|
||||
"""Família (nome_base) do candidato no TOPO do bloco renderizado."""
|
||||
return _VARIANT_BASE[_rendered_descs(mention)[0]]
|
||||
|
||||
|
||||
# (erro_de_transcricao, nome_base_esperado) — erros plausíveis de grafia/pronúncia.
|
||||
_CASES = [
|
||||
("fluidi", "Fluid"),
|
||||
("teplingo", "TapLingo"),
|
||||
("master chefe", "MasterChef"),
|
||||
("mestershef", "MasterChef"),
|
||||
("gueimiloft", "Gameloft"),
|
||||
("praim tube", "Prime Tube"),
|
||||
("tamboro", "Tamboro"),
|
||||
("kidsclube", "Kids Clube"),
|
||||
("plei kids", "Playkids"),
|
||||
("dog lovers", "Dog Lovers"),
|
||||
("fitimiap", "Fit Me APP"),
|
||||
("fuze forge", "Fuze Forge"),
|
||||
("laivifci", "LiveFC"),
|
||||
("uman fitness", "Woman Fitness"),
|
||||
("ubook", "Audiobooks UBOOK"),
|
||||
("disnei", "Disney+"),
|
||||
("pocoio", "Pocoyo"),
|
||||
("superclip", "Superclipe"),
|
||||
("trucoler", "Truecaller"),
|
||||
("trucaller", "Truecaller"),
|
||||
("globoplei", "Globoplay"),
|
||||
("netiflix", "Netflix"),
|
||||
("chat lovme", "Chat Lovme"),
|
||||
("dizer", "Deezer"),
|
||||
("star", "Star"),
|
||||
("apou", "Apple Music"),
|
||||
("babel", "Babbel"),
|
||||
("ispian", "Espn"),
|
||||
("paramaunt", "Paramount+"),
|
||||
("zodiaque", "Zodiack"),
|
||||
("instarena", "Instarena"),
|
||||
("mundome", "Mundome"),
|
||||
("jokô", "Jokoo"),
|
||||
("agebeo max", "HBO Max"),
|
||||
("tim miusic", "TIM Music"),
|
||||
]
|
||||
|
||||
# Casos hoje não cobertos pelo ranking de similaridade — documentados, não escondidos.
|
||||
_XFAIL = {}
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mention, base", _CASES, ids=[c[0] for c in _CASES])
|
||||
def test_erro_de_transcricao_ranqueia_familia_no_topo(mention: str, base: str) -> None:
|
||||
if mention in _XFAIL:
|
||||
pytest.xfail(_XFAIL[mention])
|
||||
assert _top_base(mention) == base
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mention, base", _CASES, ids=[c[0] for c in _CASES])
|
||||
def test_top_k_nao_perde_a_familia(mention: str, base: str) -> None:
|
||||
# Mesmo truncando o prompt aos N mais próximos, a família esperada continua
|
||||
# presente (recall@K). Vale até para os casos cujo TOP-1 está errado (xfail
|
||||
# acima): eles caem por volta da posição 6, ainda dentro do top-K.
|
||||
bases = {_VARIANT_BASE[d] for d in _rendered_descs(mention)}
|
||||
assert base in bases
|
||||
352
tests/migration/test_original_vas_cancellation_message.py
Normal file
352
tests/migration/test_original_vas_cancellation_message.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""Paridade do compositor determinístico com o prompt
|
||||
``fluxo_vas_cancelamento_resposta_cliente`` (regras 1.1, 2–8).
|
||||
|
||||
Os goldens de sucesso/erro/SMS vêm dos próprios exemplos do prompt em
|
||||
``builtin/prompts/vas_cancellation_response.yaml``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.domain.contas.vas_cancellation_message import (
|
||||
compose_vas_cancellation_message,
|
||||
)
|
||||
|
||||
PRT = "PRT475D8F1C1F"
|
||||
|
||||
|
||||
def test_regra_3_sucesso_com_sms_singular() -> None:
|
||||
# Exemplo do prompt: "Exemplo (sucesso com SMS)".
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"contested_items": [{"itemName": "Tamboro Mensal"}],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": True,
|
||||
"sms_not_send_error": False,
|
||||
"msisdn": "11999999999",
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O cancelamento foi concluído com sucesso. O valor contestado foi retirado "
|
||||
"da sua fatura. Enviamos um SMS com o novo código de barras e o valor "
|
||||
"atualizado. O prazo para pagamento é de 4 dias. "
|
||||
f"Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_regra_3_e_6_2_falha_no_sms_plural() -> None:
|
||||
# Exemplo do prompt: "Exemplo (sucesso com falha no SMS)".
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"contested_items": [
|
||||
{"itemName": "Tamboro Mensal"},
|
||||
{"itemName": "TIM Fashion"},
|
||||
],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": True,
|
||||
"sms_not_send_error": True,
|
||||
"msisdn": "11999999999",
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O cancelamento dos itens foi concluído com sucesso. O valor total de "
|
||||
"R$ 22,00 foi retirado da sua fatura. Identificamos uma instabilidade no "
|
||||
"envio do SMS com o novo código para pagamento. Você pode consultar o "
|
||||
"código atualizado e o prazo de pagamento diretamente no app Meu TIM. "
|
||||
f"Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_regra_2_credito_plural_com_regra_5() -> None:
|
||||
# Baseado no "Exemplo (crédito em fatura)" do prompt; o conector "No entanto,"
|
||||
# era floreio do LLM — a forma determinística segue o template literal da regra 5.
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"contested_items": [
|
||||
{"itemName": "Aluguel de Série 1"},
|
||||
{"itemName": "Aluguel de Série 2"},
|
||||
],
|
||||
"not_contested_items": [{"itemName": "Tamboro Mensal"}],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": False,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O cancelamento dos itens foi feito com sucesso. O crédito no valor total "
|
||||
"de R$ 22,00 ficou registrado como crédito para sua próxima fatura. "
|
||||
"Não foi possível contestar o item Tamboro Mensal. "
|
||||
f"Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_regra_2_credito_singular() -> None:
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"contested_items": [{"itemName": "Tamboro Mensal"}],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": False,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O cancelamento do item Tamboro Mensal no valor de R$ 22,00 foi concluído "
|
||||
"com sucesso. O valor contestado ficou registrado como crédito para sua "
|
||||
f"próxima fatura. Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_regra_8_erro_terminal_copia_literal_e_protocolo() -> None:
|
||||
# Exemplo do prompt: "Exemplo (falha, usando um dos textos de ...)".
|
||||
erro = (
|
||||
"Verifiquei aqui que este item já foi contestado anteriormente, por isso "
|
||||
"não consigo seguir com uma nova contestação. Peço que aguarde um instante."
|
||||
)
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"contestation_error_description": erro,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == f"{erro} Seu número de protocolo é {PRT}."
|
||||
|
||||
|
||||
def test_regra_8_adiciona_ponto_quando_texto_nao_termina_em_pontuacao() -> None:
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"contestation_error_description": "Não foi possível processar",
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == f"Não foi possível processar. Seu número de protocolo é {PRT}."
|
||||
|
||||
|
||||
def test_regra_8_item_ja_contestado_informa_cancelamentos_realizados() -> None:
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"cancelados": [{"servico": "A"}, {"servico": "B"}],
|
||||
"contestation_error_description": (
|
||||
"Verifiquei aqui que estes itens já foram contestados anteriormente, "
|
||||
"por isso não consigo seguir com uma nova contestação."
|
||||
),
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O cancelamento dos itens A e B foi concluído com sucesso. "
|
||||
"Verifiquei aqui que estes itens já foram contestados anteriormente, "
|
||||
"por isso não consigo seguir com uma nova contestação. "
|
||||
f"Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_regra_1_1_misto_credito() -> None:
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"itens_ja_contestados": ["Tamboro Mensal"],
|
||||
"contested_items": [{"itemName": "TIM Fashion"}],
|
||||
"contested_invoice_amount_open": "10,00",
|
||||
"sms_sent": False,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"Identifiquei que o item Tamboro Mensal já havia sido contestado "
|
||||
"anteriormente e, por esse motivo, não é possível registrar uma nova "
|
||||
"contestação para ele. Já a contestação do item TIM Fashion no valor de "
|
||||
"R$ 10,00 foi concluída com sucesso. O valor contestado ficou registrado "
|
||||
f"como crédito para sua próxima fatura. Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_regra_1_1_misto_com_sms_aplica_regra_6() -> None:
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"itens_ja_contestados": ["A", "B"],
|
||||
"contested_items": [{"itemName": "C"}, {"itemName": "D"}],
|
||||
"contested_invoice_amount_open": "30,00",
|
||||
"sms_sent": True,
|
||||
"sms_not_send_error": False,
|
||||
"msisdn": "11988887777",
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O cancelamento foi concluído com sucesso. O valor contestado foi retirado "
|
||||
"da sua fatura. Enviamos um SMS com o novo código de barras e o valor "
|
||||
"atualizado. O prazo para pagamento é de 4 dias. "
|
||||
f"Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_sms_enviado_prevalece_quando_contested_items_esta_vazio() -> None:
|
||||
# Regressão do trace 6ae8bd24: SMS efetivamente enviado nunca vira crédito.
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"itens_ja_contestados": ["Tamboro Mensal"],
|
||||
"contested_items": [],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": True,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O cancelamento foi concluído com sucesso. O valor contestado foi retirado "
|
||||
"da sua fatura. Enviamos um SMS com o novo código de barras e o valor "
|
||||
"atualizado. O prazo para pagamento é de 4 dias. "
|
||||
f"Seu número de protocolo é {PRT}."
|
||||
)
|
||||
assert "próxima fatura" not in msg
|
||||
|
||||
|
||||
def test_regra_7_protocolos_multiplos() -> None:
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"contested_items": [{"itemName": "Tamboro Mensal"}],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": False,
|
||||
"contestacao_protocols": ["PRTAAA", "PRTBBB"],
|
||||
}
|
||||
)
|
||||
assert msg.endswith("Seus números de protocolo são PRTAAA e PRTBBB.")
|
||||
|
||||
|
||||
def test_no_match_credito_todos_nao_encontrados_singular() -> None:
|
||||
# Cenário 2 (crédito): serviço não localizado na plataforma; valor contestado.
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"success": True,
|
||||
"contested_items": [{"itemName": "Tamboro Mensal"}],
|
||||
"nao_encontrados": [{"servico": "Tamboro Mensal"}],
|
||||
"cancelados": [],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": False,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O valor total de R$ 22,00 ficou registrado como crédito para sua próxima "
|
||||
"fatura. Sobre o cancelamento do serviço Tamboro Mensal, não consegui "
|
||||
"realizar por aqui. Você pode cancelar pelo App Meu TIM, na opção "
|
||||
f"Gerenciar Benefícios. Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_no_match_sms_todos_nao_encontrados() -> None:
|
||||
# Cenário 2 (SMS/boleto): valor retirado + SMS + no-match.
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"success": True,
|
||||
"contested_items": [{"itemName": "Tamboro Mensal"}],
|
||||
"nao_encontrados": [{"servico": "Tamboro Mensal"}],
|
||||
"cancelados": [],
|
||||
"contested_invoice_amount_open": "22,00",
|
||||
"sms_sent": True,
|
||||
"sms_not_send_error": False,
|
||||
"msisdn": "11999999999",
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O valor total de R$ 22,00 foi retirado da sua fatura. Enviamos um SMS com "
|
||||
"o novo código de barras e o valor atualizado. O prazo para pagamento é de "
|
||||
"4 dias. Sobre o cancelamento do serviço Tamboro "
|
||||
"Mensal, não consegui realizar por aqui. Você pode cancelar pelo App Meu "
|
||||
f"TIM, na opção Gerenciar Benefícios. Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_no_match_misto_credito() -> None:
|
||||
# Cenário 3 (crédito): A,B,C cancelados; D,E,F no-match.
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"success": True,
|
||||
"contested_items": [{"itemName": x} for x in ("A", "B", "C", "D", "E", "F")],
|
||||
"cancelados": [{"servico": x} for x in ("A", "B", "C")],
|
||||
"nao_encontrados": [{"servico": x} for x in ("D", "E", "F")],
|
||||
"contested_invoice_amount_open": "60,00",
|
||||
"sms_sent": False,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O valor total de R$ 60,00 ficou registrado como crédito para sua próxima "
|
||||
"fatura. O cancelamento dos serviços A, B e C foram realizados com sucesso. "
|
||||
"Não consegui realizar por aqui o cancelamento dos serviços D, E e F. Você "
|
||||
"pode cancelá-los pelo App Meu TIM, na opção Gerenciar Benefícios. "
|
||||
f"Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_no_match_misto_sms() -> None:
|
||||
# Cenário 3 (SMS/boleto).
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"success": True,
|
||||
"contested_items": [{"itemName": x} for x in ("A", "B", "C", "D", "E", "F")],
|
||||
"cancelados": [{"servico": x} for x in ("A", "B", "C")],
|
||||
"nao_encontrados": [{"servico": x} for x in ("D", "E", "F")],
|
||||
"contested_invoice_amount_open": "60,00",
|
||||
"sms_sent": True,
|
||||
"sms_not_send_error": False,
|
||||
"msisdn": "11988887777",
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O valor total de R$ 60,00 foi retirado da sua fatura. Enviamos um SMS com "
|
||||
"o novo código de barras e o valor atualizado. O prazo para pagamento é de "
|
||||
"4 dias. O cancelamento dos serviços A, B e C foram "
|
||||
"realizados com sucesso. Não consegui realizar por aqui o cancelamento dos "
|
||||
"serviços D, E e F. Você pode cancelá-los pelo App Meu TIM, na opção "
|
||||
f"Gerenciar Benefícios. Seu número de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_no_match_misto_singular_cada_lista() -> None:
|
||||
# Concordância: 1 cancelado + 1 no-match.
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"success": True,
|
||||
"contested_items": [{"itemName": "A"}, {"itemName": "B"}],
|
||||
"cancelados": [{"servico": "A"}],
|
||||
"nao_encontrados": [{"servico": "B"}],
|
||||
"contested_invoice_amount_open": "20,00",
|
||||
"sms_sent": False,
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == (
|
||||
"O valor total de R$ 20,00 ficou registrado como crédito para sua próxima "
|
||||
"fatura. O cancelamento do serviço A foi realizado com sucesso. Não "
|
||||
"consegui realizar por aqui o cancelamento do serviço B. Você pode "
|
||||
f"cancelá-lo pelo App Meu TIM, na opção Gerenciar Benefícios. Seu número "
|
||||
f"de protocolo é {PRT}."
|
||||
)
|
||||
|
||||
|
||||
def test_sucesso_sem_itens_detalhados_gera_confirmacao_generica() -> None:
|
||||
# Sem itens contestados/não contestados/já contestados, mas cancelamento OK:
|
||||
# confirmação neutra + protocolo (regra 7).
|
||||
msg = compose_vas_cancellation_message(
|
||||
{
|
||||
"success": True,
|
||||
"contested_items": [],
|
||||
"not_contested_items": [],
|
||||
"contestacao_protocol": PRT,
|
||||
}
|
||||
)
|
||||
assert msg == f"O cancelamento foi concluído com sucesso. Seu número de protocolo é {PRT}."
|
||||
|
||||
|
||||
def test_sucesso_sem_itens_e_sem_protocolo_so_confirmacao() -> None:
|
||||
# Protocolo de cancelamento (não de contestação) é anexado pelo backend, não aqui.
|
||||
msg = compose_vas_cancellation_message({"success": True, "contested_items": []})
|
||||
assert msg == "O cancelamento foi concluído com sucesso."
|
||||
|
||||
|
||||
def test_payload_nao_classificavel_sem_sucesso_retorna_vazio() -> None:
|
||||
assert compose_vas_cancellation_message({}) == ""
|
||||
assert compose_vas_cancellation_message({"success": False}) == ""
|
||||
assert compose_vas_cancellation_message("nao-mapping") == "" # type: ignore[arg-type]
|
||||
1436
tests/migration/test_original_vas_variation.py
Normal file
1436
tests/migration/test_original_vas_variation.py
Normal file
File diff suppressed because it is too large
Load Diff
692
tests/migration/test_original_vas_variation_adversarial.py
Normal file
692
tests/migration/test_original_vas_variation_adversarial.py
Normal file
@@ -0,0 +1,692 @@
|
||||
"""Suíte ADVERSARIAL do casamento ``invoiceVariation`` → ``invoice_detail``.
|
||||
|
||||
Contraexemplos difíceis para uma **futura** camada de similaridade. Nada aqui liga
|
||||
similaridade no fluxo real: os 50 casos fixam o que o matcher determinístico faz hoje
|
||||
e medem, em **shadow**, o score que uma camada de similaridade veria — para responder
|
||||
*antes* de mexer em produção se existe threshold que separe com segurança.
|
||||
|
||||
Assimetria que governa o desenho: nesta rota a fila de cancelamento é autorizada **sem
|
||||
confirmação pendente** (``runtime.py`` ``_handle_execute_action_queue``), então um falso
|
||||
positivo não vira pergunta ao cliente — vira **cancelamento executado**. Logo::
|
||||
|
||||
FALSO POSITIVO >> FALSO NEGATIVO na dúvida, NÃO CASAR
|
||||
|
||||
Os nomes de serviço são reais do repositório (fixtures/cenários): ``Fluid Light`` ×
|
||||
``Fluid Premium``, ``Aya Books Premium`` × ``Aya Audiobooks Premium``, ``EXA Cloud
|
||||
500GB`` × ``EXA Segurança Premium``, ``Fit Me App``. Colisões inventadas seriam mais
|
||||
fáceis que a realidade.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.contas.invoice_resolver import InvoiceResolver
|
||||
from app.domain.contas.item_matcher import SimilarityItemMatcher
|
||||
from app.domain.contas.vas_variation import varied_avulso_items
|
||||
|
||||
CURRENT = "2026-04-07T00:00:00.000Z"
|
||||
|
||||
LINE_A = "11999999999"
|
||||
LINE_B = "11888888888"
|
||||
|
||||
# Seções por classe resultante (``invoice_resolver.SECTION_DEFAULTS``).
|
||||
AVULSO = "SVA Detalhe Total"
|
||||
EVENTUAIS = "Itens Eventuais"
|
||||
ESTRATEGICO = "Serviços Contratados de Terceiros"
|
||||
BUNDLE = "Serviços Bundle Inclusos"
|
||||
# Fora de SECTION_DEFAULTS → o item existe na fatura mas não é acionável.
|
||||
SEM_CLASSE = "Mensalidades Adicionais"
|
||||
|
||||
|
||||
# ----- modelo dos casos -------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entry:
|
||||
"""Uma linha de item no ``invoice_detail`` (PDF parseado)."""
|
||||
|
||||
desc: str
|
||||
value: str
|
||||
section: str = AVULSO
|
||||
msisdn: str = LINE_A
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Case:
|
||||
"""Um contraexemplo.
|
||||
|
||||
``expected`` são os ``canonical_name`` que o matcher PODE devolver como
|
||||
canceláveis. Tupla vazia = **falha fechada** (o desfecho seguro por default)."""
|
||||
|
||||
case_id: str
|
||||
category: str
|
||||
var_desc: str
|
||||
var_value: str
|
||||
invoice: tuple[Entry, ...]
|
||||
expected: tuple[str, ...]
|
||||
why: str
|
||||
# Linha esperada do item casado, quando o caso testa roteamento de msisdn.
|
||||
expected_msisdn: str | None = None
|
||||
|
||||
|
||||
# ----- builders ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _variation(desc: str, value: str) -> dict:
|
||||
"""``invoiceVariation`` de uma cobrança que ENTROU na fatura atual.
|
||||
|
||||
Um único vencimento no bloco de variação, desempatado por ``currentInvoice`` —
|
||||
a forma que o runtime entrega quando a fatura passada não tinha o avulso."""
|
||||
item = {
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Serviços de valor adicionado",
|
||||
"nItems": "1",
|
||||
"items": [{"desc": desc, "value": value, "invoice": CURRENT}],
|
||||
}
|
||||
current = {
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Serviços de valor adicionado",
|
||||
"items": [{"desc": desc, "value": value, "contestable": True}],
|
||||
}
|
||||
return {"invoiceVariation": [item], "currentInvoice": [current]}
|
||||
|
||||
|
||||
def _detail(entries: tuple[Entry, ...]) -> dict:
|
||||
"""``invoice_detail`` no formato ``{msisdn: {seção: [entries]}}``."""
|
||||
buckets: dict[str, dict[str, list]] = {}
|
||||
for entry in entries:
|
||||
section = buckets.setdefault(entry.msisdn, {}).setdefault(entry.section, [])
|
||||
section.append({"desc": entry.desc, "value": entry.value})
|
||||
return buckets
|
||||
|
||||
|
||||
def _run(case: Case):
|
||||
"""Executa o matcher REAL de produção sobre o caso."""
|
||||
return varied_avulso_items(
|
||||
invoice_variation=_variation(case.var_desc, case.var_value),
|
||||
invoice_detail=_detail(case.invoice),
|
||||
resolver=InvoiceResolver(),
|
||||
by_charge=False,
|
||||
)
|
||||
|
||||
|
||||
# ----- A. colisões semânticas (15) --------------------------------------------
|
||||
# Produtos DIFERENTES com nomes quase idênticos e MESMO valor. Similaridade alta
|
||||
# sozinha nunca pode autorizar cancelamento.
|
||||
|
||||
_A: tuple[Case, ...] = (
|
||||
Case(
|
||||
"A01-fluid-light-x-premium", "colisao", "Fluid Light", "12.90",
|
||||
(Entry("Fluid Premium", "12.90"),), (),
|
||||
"Light × Premium: mesma marca, produto e preço diferentes de propósito",
|
||||
),
|
||||
Case(
|
||||
"A02-fluid-premium-x-light", "colisao", "Fluid Premium", "12.90",
|
||||
(Entry("Fluid Light", "12.90"),), (),
|
||||
"inverso de A01 — a direção não pode salvar o casamento",
|
||||
),
|
||||
Case(
|
||||
"A03-aya-books-x-audiobooks", "colisao", "Aya Books Premium", "9.99",
|
||||
(Entry("Aya Audiobooks Premium", "9.99"),), (),
|
||||
"um token difere e ele CONTÉM o outro (books ⊂ audiobooks)",
|
||||
),
|
||||
Case(
|
||||
"A04-aya-ensinah-x-books", "colisao", "Aya Ensinah Premium", "9.99",
|
||||
(Entry("Aya Books Premium", "9.99"),), (),
|
||||
"2 de 3 tokens iguais, mesmo preço",
|
||||
),
|
||||
Case(
|
||||
"A05-exa-seguranca-x-cloud", "colisao", "EXA Segurança Premium", "19.99",
|
||||
(Entry("EXA Cloud 500GB", "19.99"),), (),
|
||||
"mesma marca EXA, serviços sem relação",
|
||||
),
|
||||
Case(
|
||||
"A06-bancah-jornais-x-premium", "colisao", "Bancah Jornais II", "7.99",
|
||||
(Entry("Bancah Premium + Jornais", "7.99"),), (),
|
||||
"mesmos tokens em ORDEM diferente — bag-of-words casaria",
|
||||
),
|
||||
Case(
|
||||
"A07-itgame-light-x-premium", "colisao", "ITGame Light", "14.99",
|
||||
(Entry("ITGame Premium", "14.99"),), (),
|
||||
"Light × Premium na mesma marca",
|
||||
),
|
||||
Case(
|
||||
"A08-aluguel-filme-1-x-2", "colisao", "Aluguel de Filme 1", "19.90",
|
||||
(Entry("Aluguel de Filme 2", "19.90"),), (),
|
||||
"difere só no dígito final — Jaro-Winkler dá score altíssimo",
|
||||
),
|
||||
Case(
|
||||
"A09-tim-games-mensal-x-semanal", "colisao", "TIM Games Mensal", "16.99",
|
||||
(Entry("TIM Games Semanal", "16.99"),), (),
|
||||
"Mensal × Semanal: periodicidade diferente é PRODUTO diferente",
|
||||
),
|
||||
Case(
|
||||
"A10-kids-plus-x-premium", "colisao", "Kids Plus", "11.99",
|
||||
(Entry("Kids Premium", "11.99"),), (),
|
||||
"Plus × Premium",
|
||||
),
|
||||
Case(
|
||||
"A11-exa-premium-x-protecao", "colisao", "EXA Segurança Premium", "19.99",
|
||||
(Entry("EXA Segurança + Proteção Stand", "19.99"),), (),
|
||||
"2 tokens iniciais idênticos, cauda diferente",
|
||||
),
|
||||
Case(
|
||||
"A12-tim-games-prefixo", "colisao", "TIM Games", "16.99",
|
||||
(Entry("TIM Games Premium", "16.99"),), (),
|
||||
"PREFIXO DE TOKENS entre produtos DIFERENTES — indistinguível de FIT ME "
|
||||
"pela forma; só o valor separaria, e aqui ele coincide",
|
||||
),
|
||||
Case(
|
||||
"A13-fit-app-x-fit-me-app", "colisao", "Fit App", "16.99",
|
||||
(Entry("Fit Me App", "16.99"),), (),
|
||||
"token inserido no MEIO — não é prefixo, mas a grafia é quase idêntica",
|
||||
),
|
||||
Case(
|
||||
"A14-tim-music-plus-x-premium", "colisao", "Tim Music Plus", "9.90",
|
||||
(Entry("Tim Music Premium", "9.90"),), (),
|
||||
"difere só no último token",
|
||||
),
|
||||
Case(
|
||||
"A15-kids-prefixo", "colisao", "Kids", "11.99",
|
||||
(Entry("Kids Premium", "11.99"),), (),
|
||||
"segundo prefixo perigoso: nome curto genérico casaria com qualquer coisa",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----- B. diferença mínima de valor (8) ---------------------------------------
|
||||
# Nome idêntico (ou prefixo perfeito), candidato único, similaridade 1.0 — e o
|
||||
# valor difere. Um centavo já basta para bloquear.
|
||||
|
||||
_B: tuple[Case, ...] = (
|
||||
Case(
|
||||
"B01-fitme-16.99-x-17.00", "valor", "Fit Me App", "16.99",
|
||||
(Entry("Fit Me App", "17.00"),), (),
|
||||
"nome idêntico, 1 centavo acima",
|
||||
),
|
||||
Case(
|
||||
"B02-fitme-16.99-x-16.98", "valor", "Fit Me App", "16.99",
|
||||
(Entry("Fit Me App", "16.98"),), (),
|
||||
"nome idêntico, 1 centavo abaixo",
|
||||
),
|
||||
Case(
|
||||
"B03-fluid-14.99-x-15.00", "valor", "Fluid Premium", "14.99",
|
||||
(Entry("Fluid Premium", "15.00"),), (),
|
||||
"cruza a fronteira de unidade",
|
||||
),
|
||||
Case(
|
||||
"B04-exa-9.99-x-9.98", "valor", "EXA Cloud 500GB", "9.99",
|
||||
(Entry("EXA Cloud 500GB", "9.98"),), (),
|
||||
"1 centavo em valor baixo",
|
||||
),
|
||||
Case(
|
||||
"B05-fitme-prefixo-valor-errado", "valor", "FIT ME App", "16.99",
|
||||
(Entry("FIT ME App Premium Mensal", "17.00"),), (),
|
||||
"OBRIGATÓRIO §5: o caso real, com o valor divergente — prefixo válido não "
|
||||
"compensa centavo errado",
|
||||
),
|
||||
Case(
|
||||
"B06-aya-24.90-x-24.91", "valor", "Aya Books Premium", "24.90",
|
||||
(Entry("Aya Books Premium", "24.91"),), (),
|
||||
"1 centavo com nome longo idêntico",
|
||||
),
|
||||
Case(
|
||||
"B07-itgame-12.90-x-12.09", "valor", "ITGame Light", "12.90",
|
||||
(Entry("ITGame Light", "12.09"),), (),
|
||||
"dígitos transpostos — erro de parser plausível",
|
||||
),
|
||||
Case(
|
||||
"B08-fitme-16.99-x-169.90", "valor", "Fit Me App", "16.99",
|
||||
(Entry("Fit Me App", "169.90"),), (),
|
||||
"vírgula deslocada — 10× o valor",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----- C. estratégico disfarçado de avulso (7) --------------------------------
|
||||
# A classificação da FATURA vence a da API. Nome e valor idênticos, candidato
|
||||
# único, similaridade máxima — e mesmo assim não é cancelável.
|
||||
|
||||
_C: tuple[Case, ...] = (
|
||||
Case(
|
||||
"C01-hbo-secao-terceiros", "estrategico", "HBO Max Standard", "29.90",
|
||||
(Entry("HBO Max Standard", "29.90", section=ESTRATEGICO),), (),
|
||||
"seção de terceiros → item_type=estrategico",
|
||||
),
|
||||
Case(
|
||||
"C02-netflix-em-secao-avulsa", "estrategico", "Netflix Premium", "39.90",
|
||||
(Entry("Netflix Premium", "39.90", section=AVULSO),), (),
|
||||
"guard 1: nome na lista fixa §12.A vence a seção avulsa",
|
||||
),
|
||||
Case(
|
||||
"C03-youtube-mis-bucket", "estrategico", "YouTube Premium Mensal", "24.90",
|
||||
(Entry("YouTube Premium Mensal", "24.90", section=AVULSO),), (),
|
||||
"caso REAL documentado: a API bucketa streaming como avulso",
|
||||
),
|
||||
Case(
|
||||
"C04-deezer-em-secao-avulsa", "estrategico", "Deezer Premium", "19.90",
|
||||
(Entry("Deezer Premium", "19.90", section=AVULSO),), (),
|
||||
"guard 1 sobre marca da lista fixa",
|
||||
),
|
||||
Case(
|
||||
"C05-apple-music-em-secao-avulsa", "estrategico", "Apple Music SVA Mes", "16.90",
|
||||
(Entry("Apple Music SVA Mes", "16.90", section=AVULSO),), (),
|
||||
"marca de duas palavras na lista fixa",
|
||||
),
|
||||
Case(
|
||||
"C06-bundle-incluso", "estrategico", "Tamboro Mensal", "19.99",
|
||||
(Entry("Tamboro Mensal", "19.99", section=BUNDLE),), (),
|
||||
"bundle incluso no plano: não se cancela item de bundle",
|
||||
),
|
||||
Case(
|
||||
"C07-fitme-classificado-estrategico", "estrategico", "FIT ME App", "16.99",
|
||||
(Entry("FIT ME App Premium Mensal", "16.99", section=ESTRATEGICO),), (),
|
||||
"OBRIGATÓRIO §5: o caso real, mas a fatura o classifica estratégico",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----- D. ambiguidade extrema (8) ---------------------------------------------
|
||||
# 2+ candidatos plausíveis. Nunca escolher o de maior score: falha FECHADA.
|
||||
|
||||
_D: tuple[Case, ...] = (
|
||||
Case(
|
||||
"D01-fitme-dois-candidatos", "ambiguidade", "FIT ME App", "16.99",
|
||||
(
|
||||
Entry("FIT ME App Premium", "16.99"),
|
||||
Entry("FIT ME App Premium Mensal", "16.99"),
|
||||
),
|
||||
(),
|
||||
"OBRIGATÓRIO §5: dois prefixos válidos, mesmo valor — cancelar o errado "
|
||||
"custa o serviço de um cliente real",
|
||||
),
|
||||
Case(
|
||||
"D02-fitme-tres-candidatos", "ambiguidade", "FIT ME App", "16.99",
|
||||
(
|
||||
Entry("FIT ME App Premium", "16.99"),
|
||||
Entry("FIT ME App Premium Mensal", "16.99"),
|
||||
Entry("FIT ME App Premium Anual", "16.99"),
|
||||
),
|
||||
(),
|
||||
"três candidatos: mais opções não tornam a escolha mais segura",
|
||||
),
|
||||
Case(
|
||||
"D03-aya-tres-marcas", "ambiguidade", "Aya", "9.99",
|
||||
(
|
||||
Entry("Aya Books Premium", "9.99"),
|
||||
Entry("Aya Audiobooks Premium", "9.99"),
|
||||
Entry("Aya Ensinah Premium", "9.99"),
|
||||
),
|
||||
(),
|
||||
"prefixo de marca casa a família inteira",
|
||||
),
|
||||
Case(
|
||||
"D04-mesmo-nome-duas-linhas", "ambiguidade", "Fit Me App", "16.99",
|
||||
(
|
||||
Entry("Fit Me App", "16.99", msisdn=LINE_A),
|
||||
Entry("Fit Me App", "16.99", msisdn=LINE_B),
|
||||
),
|
||||
(),
|
||||
"nome e valor idênticos em DUAS linhas: cancelar na linha errada é dano real",
|
||||
),
|
||||
Case(
|
||||
"D05-duplicata-mesma-linha", "ambiguidade", "Fit Me App", "16.99",
|
||||
(
|
||||
Entry("Fit Me App", "16.99"),
|
||||
Entry("Fit Me App", "16.99"),
|
||||
),
|
||||
("Fit Me App",),
|
||||
"cobrança duplicada na MESMA linha e seção: o dedupe do resolver colapsa "
|
||||
"em uma, e uma variação consome exatamente uma",
|
||||
),
|
||||
Case(
|
||||
"D06-fluid-familia", "ambiguidade", "Fluid", "12.90",
|
||||
(
|
||||
Entry("Fluid Light", "12.90"),
|
||||
Entry("Fluid Premium", "12.90"),
|
||||
),
|
||||
(),
|
||||
"marca sozinha não identifica o produto",
|
||||
),
|
||||
Case(
|
||||
"D07-exa-familia", "ambiguidade", "EXA", "19.99",
|
||||
(
|
||||
Entry("EXA Cloud 500GB", "19.99"),
|
||||
Entry("EXA Segurança Premium", "19.99"),
|
||||
),
|
||||
(),
|
||||
"dois produtos sem relação sob a mesma marca",
|
||||
),
|
||||
Case(
|
||||
"D08-bancah-familia", "ambiguidade", "Bancah", "7.99",
|
||||
(
|
||||
Entry("Bancah Jornais II", "7.99"),
|
||||
Entry("Bancah Premium + Jornais", "7.99"),
|
||||
),
|
||||
(),
|
||||
"scores praticamente empatados",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----- E. prefixo / substring perigosos (6) -----------------------------------
|
||||
# Só a direção comprovada é permitida: variação CURTA → fatura QUALIFICADA.
|
||||
|
||||
_E: tuple[Case, ...] = (
|
||||
Case(
|
||||
"E01-prefixo-invertido", "prefixo", "Fit Me App Premium", "16.99",
|
||||
(Entry("Fit Me App", "16.99"),), (),
|
||||
"variação MAIS específica que a fatura: direção proibida",
|
||||
),
|
||||
Case(
|
||||
"E02-substring-no-meio", "prefixo", "Me App", "16.99",
|
||||
(Entry("Fit Me App Premium Mensal", "16.99"),), (),
|
||||
"casa no meio do nome — substring solta cancelaria",
|
||||
),
|
||||
Case(
|
||||
"E03-sufixo-games-premium", "prefixo", "Games Premium", "16.99",
|
||||
(Entry("TIM Games Premium", "16.99"),), (),
|
||||
"sufixo, não prefixo",
|
||||
),
|
||||
Case(
|
||||
"E04-token-parcial-tube", "prefixo", "Tube Premium", "24.90",
|
||||
(Entry("YouTube Premium", "24.90"),), (),
|
||||
"pedaço de token: 'Tube' ⊂ 'YouTube' sem ser token",
|
||||
),
|
||||
Case(
|
||||
"E05-token-solto-app", "prefixo", "App", "16.99",
|
||||
(Entry("Fit Me App", "16.99"),), (),
|
||||
"token genérico no fim casaria meia fatura",
|
||||
),
|
||||
Case(
|
||||
"E06-fitme-fail-closed-por-padrao", "prefixo", "FIT ME App", "16.99",
|
||||
(Entry("FIT ME App Premium Mensal", "16.99"),), (),
|
||||
"OBRIGATÓRIO §5: o caso real do incidente e081812886a. Com "
|
||||
"TIM_VAS_QUALIFIED_NAME_MATCH desligada (padrão) ele NÃO casa — é o custo "
|
||||
"aceito de eliminar FP-1: 'FIT ME App'→'FIT ME App Premium Mensal' e "
|
||||
"'TIM Games'→'TIM Games Premium' têm a MESMA forma, e nenhum sinal "
|
||||
"disponível os separa. Ver test_flag_liga_a_segunda_passada_e_so_isso.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ----- F. invariantes estruturais (6) -----------------------------------------
|
||||
|
||||
_F: tuple[Case, ...] = (
|
||||
Case(
|
||||
"F01-msisdn-ausente", "estrutural", "Fit Me App", "16.99",
|
||||
(Entry("Fit Me App", "16.99", msisdn=""),), (),
|
||||
"sem linha não há como executar o cancelamento: falha fechada",
|
||||
),
|
||||
Case(
|
||||
"F02-item-na-linha-secundaria", "estrutural", "Fit Me App", "16.99",
|
||||
(
|
||||
Entry("Fluid Light", "12.90", msisdn=LINE_A),
|
||||
Entry("Fit Me App", "16.99", msisdn=LINE_B),
|
||||
),
|
||||
("Fit Me App",),
|
||||
"casa na linha CERTA (B), não na primeira do payload",
|
||||
LINE_B,
|
||||
),
|
||||
Case(
|
||||
"F03-secao-sem-classe", "estrutural", "Fit Me App", "16.99",
|
||||
(Entry("Fit Me App", "16.99", section=SEM_CLASSE),), (),
|
||||
"seção fora de SECTION_DEFAULTS → item existe mas não é acionável",
|
||||
),
|
||||
Case(
|
||||
"F04-desc-vazia", "estrutural", "Fit Me App", "16.99",
|
||||
(Entry("", "16.99"),), (),
|
||||
"item sem nome não tem identidade: charge_match_key devolve None",
|
||||
),
|
||||
Case(
|
||||
"F05-valor-nao-parseavel", "estrutural", "Fit Me App", "16.99",
|
||||
(Entry("Fit Me App", "indisponível"),), (),
|
||||
"valor ilegível no PDF não pode virar cancelamento",
|
||||
),
|
||||
Case(
|
||||
"F06-fatura-sem-o-item", "estrutural", "Fit Me App", "16.99",
|
||||
(Entry("Fluid Light", "12.90"), Entry("EXA Cloud 500GB", "9.99")), (),
|
||||
"a API diz que variou, a fatura não tem: falha fechada (SPEC §9)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
ALL_CASES: tuple[Case, ...] = _A + _B + _C + _D + _E + _F
|
||||
|
||||
assert len(ALL_CASES) == 50, f"a suíte exige exatamente 50 casos, tem {len(ALL_CASES)}"
|
||||
|
||||
|
||||
# ----- falsos positivos conhecidos -------------------------------------------
|
||||
# VAZIO desde o fix dos 4 FPs que esta suíte encontrou (FP-1/FP-1' prefixo de
|
||||
# tokens, FP-2 ambiguidade de linha, FP-3 msisdn ausente). Mantido como ponto de
|
||||
# extensão: um caso novo que o matcher viole entra aqui com `strict=True`, e o
|
||||
# XPASS quebra a suíte no dia em que for corrigido — a lista nunca envelhece em
|
||||
# silêncio. Enquanto estiver vazia, TODOS os 50 casos passam de verdade.
|
||||
KNOWN_UNSAFE: dict[str, str] = {}
|
||||
|
||||
|
||||
def _cases_param():
|
||||
"""Parametrização materializada; compatível com pytest 9/10."""
|
||||
rows = []
|
||||
for case in ALL_CASES:
|
||||
reason = KNOWN_UNSAFE.get(case.case_id)
|
||||
marks = [pytest.mark.xfail(reason=reason, strict=True)] if reason else []
|
||||
rows.append(pytest.param(case, id=case.case_id, marks=marks))
|
||||
return rows
|
||||
|
||||
|
||||
def _shadow_rows() -> list[tuple[Case, float, bool]]:
|
||||
"""(caso, score de similaridade, deve_casar) para os 50 — sem tocar produção."""
|
||||
matcher = SimilarityItemMatcher()
|
||||
rows = []
|
||||
for case in ALL_CASES:
|
||||
candidates = [e.desc for e in case.invoice if e.desc]
|
||||
score = matcher.best_similarity(case.var_desc, candidates) if candidates else 0.0
|
||||
rows.append((case, score, bool(case.expected)))
|
||||
return rows
|
||||
|
||||
|
||||
# ----- testes -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_suite_tem_exatamente_50_casos_com_a_distribuicao_exigida():
|
||||
"""A distribuição é parte do contrato da suíte, não detalhe."""
|
||||
from collections import Counter
|
||||
|
||||
assert len(ALL_CASES) == 50
|
||||
assert Counter(c.category for c in ALL_CASES) == {
|
||||
"colisao": 15,
|
||||
"valor": 8,
|
||||
"estrategico": 7,
|
||||
"ambiguidade": 8,
|
||||
"prefixo": 6,
|
||||
"estrutural": 6,
|
||||
}
|
||||
assert len({c.case_id for c in ALL_CASES}) == 50, "case_id duplicado"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _cases_param())
|
||||
def test_matcher_determinístico_nao_produz_falso_positivo(case: Case):
|
||||
"""O matcher de produção devolve exatamente o conjunto seguro.
|
||||
|
||||
Tupla vazia = falha fechada. Qualquer item a mais é um FALSO POSITIVO, e nesta
|
||||
rota falso positivo é cancelamento executado sem confirmação."""
|
||||
items = _run(case)
|
||||
|
||||
resolved = [] if items is None else [it.canonical_name for it in items]
|
||||
assert resolved == list(case.expected), (
|
||||
f"{case.case_id} ({case.category}): esperado {list(case.expected)}, "
|
||||
f"veio {resolved} — {case.why}"
|
||||
)
|
||||
|
||||
if case.expected_msisdn is not None and items:
|
||||
assert [it.msisdn for it in items] == [case.expected_msisdn], (
|
||||
f"{case.case_id}: cancelamento roteado para a linha errada"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
pytest.param(
|
||||
c,
|
||||
id=c.case_id,
|
||||
marks=(
|
||||
[pytest.mark.xfail(reason=KNOWN_UNSAFE[c.case_id], strict=True)]
|
||||
if c.case_id in KNOWN_UNSAFE
|
||||
else []
|
||||
),
|
||||
)
|
||||
for c in ALL_CASES
|
||||
],
|
||||
)
|
||||
def test_nenhum_item_devolvido_e_estrategico_ou_bundle(case: Case):
|
||||
"""Invariante transversal: o que sai do matcher é sempre cancelável.
|
||||
|
||||
Independe da categoria — nenhum caminho pode devolver estratégico/bundle,
|
||||
nem item sem linha de destino."""
|
||||
items = _run(case) or []
|
||||
assert all(it.item_type == "avulso" for it in items), (
|
||||
f"{case.case_id}: item não-avulso na fila de cancelamento"
|
||||
)
|
||||
assert all(it.msisdn for it in items), (
|
||||
f"{case.case_id}: item sem msisdn na fila de cancelamento"
|
||||
)
|
||||
|
||||
|
||||
def test_valores_sao_decimal_nunca_float():
|
||||
"""``float`` em dinheiro reintroduz o erro de centavo que a suíte B testa."""
|
||||
items = _run(_F[1]) or [] # F02: casa na linha secundária
|
||||
assert items and all(isinstance(it.value, Decimal) for it in items)
|
||||
|
||||
|
||||
def test_flag_liga_a_segunda_passada_e_so_isso(monkeypatch):
|
||||
"""``TIM_VAS_QUALIFIED_NAME_MATCH`` governa exatamente o prefixo de tokens.
|
||||
|
||||
Ligada, o FIT ME volta a casar — e os falsos positivos da MESMA forma
|
||||
(``TIM Games``→``TIM Games Premium``) voltam junto. É essa inseparabilidade
|
||||
que justifica o default desligado: a flag não escolhe "casar só o certo",
|
||||
escolhe aceitar ou recusar a classe inteira."""
|
||||
from app.domain.contas import vas_variation
|
||||
|
||||
fitme = next(c for c in ALL_CASES if c.case_id.startswith("E06"))
|
||||
tim_games = next(c for c in ALL_CASES if c.case_id.startswith("A12"))
|
||||
|
||||
assert _run(fitme) == [] # default: fail closed
|
||||
assert _run(tim_games) == []
|
||||
|
||||
monkeypatch.setattr(vas_variation, "_VAS_QUALIFIED_NAME_MATCH", True)
|
||||
assert [it.canonical_name for it in _run(fitme) or []] == [
|
||||
"FIT ME App Premium Mensal"
|
||||
]
|
||||
# o falso positivo volta junto — a prova de que não são separáveis
|
||||
assert [it.canonical_name for it in _run(tim_games) or []] == ["TIM Games Premium"]
|
||||
|
||||
|
||||
def test_flag_ligada_nao_reabre_msisdn_nem_ambiguidade(monkeypatch):
|
||||
"""A flag reabre SÓ o prefixo de tokens: os guards de msisdn e de ambiguidade
|
||||
de linha valem nos dois modos."""
|
||||
from app.domain.contas import vas_variation
|
||||
|
||||
monkeypatch.setattr(vas_variation, "_VAS_QUALIFIED_NAME_MATCH", True)
|
||||
for prefix in ("F01", "D04", "D01"):
|
||||
case = next(c for c in ALL_CASES if c.case_id.startswith(prefix))
|
||||
assert _run(case) == [], f"{case.case_id} reaberto com a flag ligada"
|
||||
|
||||
|
||||
# ----- shadow: o que uma camada de similaridade veria -------------------------
|
||||
|
||||
|
||||
def test_shadow_similaridade_tem_overlap_irremovivel():
|
||||
"""Positivos e negativos se SOBREPÕEM no eixo de similaridade.
|
||||
|
||||
Achado central: o menor score de um caso que DEVE casar fica ABAIXO do maior
|
||||
score de um caso que NÃO pode casar. Nenhum corte único separa os dois lados —
|
||||
a similaridade não é o discriminante deste fluxo."""
|
||||
rows = _shadow_rows()
|
||||
min_pos = min(s for _, s, safe in rows if safe)
|
||||
max_neg = max(s for _, s, safe in rows if not safe)
|
||||
|
||||
assert max_neg >= min_pos, (
|
||||
"sem overlap — se isto falhar, a suíte perdeu seus contraexemplos duros "
|
||||
"e precisa de casos novos antes de servir como evidência"
|
||||
)
|
||||
# Ancorado no medido: negativos chegam a 1.00 — nome IDÊNTICO com valor
|
||||
# diferente (bloco B) e estratégico mis-bucketado (bloco C) pontuam o máximo
|
||||
# e mesmo assim não podem ser cancelados. Nenhum corte superior a esse existe,
|
||||
# então nenhum threshold separa por cima.
|
||||
assert max_neg >= 1.0
|
||||
assert max_neg >= min_pos
|
||||
|
||||
|
||||
@pytest.mark.parametrize("threshold", (0.85, 0.90, 0.95, 0.97, 0.99))
|
||||
def test_nenhum_threshold_de_similaridade_zera_falso_positivo(threshold: float):
|
||||
"""Para TODO corte avaliado sobra pelo menos um falso positivo.
|
||||
|
||||
A similaridade sozinha jamais autoriza cancelamento: o que separa os casos é
|
||||
o **valor ao centavo** e a **classe vinda da fatura** — nomes idênticos com
|
||||
valores diferentes (bloco B) e estratégicos mis-bucketados (bloco C) pontuam
|
||||
1.0 e mesmo assim não podem ser cancelados."""
|
||||
rows = _shadow_rows()
|
||||
false_positives = [c.case_id for c, s, safe in rows if not safe and s >= threshold]
|
||||
|
||||
assert false_positives, (
|
||||
f"threshold {threshold} zerou os FP — revisar: o veredito "
|
||||
"NOT_SAFE_FOR_AUTO_EXECUTION depende deste contraexemplo"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[pytest.param(c, id=c.case_id) for c in ALL_CASES if c.category == "prefixo"],
|
||||
)
|
||||
def test_guards_de_prefixo_valem_com_a_flag_ligada(case: Case, monkeypatch):
|
||||
"""Os guards do prefixo de tokens precisam valer QUANDO ele roda.
|
||||
|
||||
Com a flag no default (off) o bloco E passaria por vacuidade — nada casa de
|
||||
qualquer jeito, e uma regressão em ``_is_token_prefix`` (aceitar substring no
|
||||
meio, aceitar prefixo invertido) não seria detectada por ninguém. Aqui a
|
||||
segunda passada é ligada de propósito, então os guards são exercitados."""
|
||||
from app.domain.contas import vas_variation
|
||||
|
||||
monkeypatch.setattr(vas_variation, "_VAS_QUALIFIED_NAME_MATCH", True)
|
||||
items = _run(case) or []
|
||||
resolved = [it.canonical_name for it in items]
|
||||
|
||||
# E06 é o FIT ME: com a flag ligada ele CASA (é o comportamento da passada).
|
||||
esperado = ["FIT ME App Premium Mensal"] if case.case_id.startswith("E06") else []
|
||||
assert resolved == esperado, f"{case.case_id}: {case.why}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", [pytest.param(c, id=c.case_id) for c in ALL_CASES])
|
||||
def test_explicacao_sem_data_nao_altera_nenhum_dos_50(case: Case):
|
||||
"""Prova de propriedade: com os detalhes SEM ``period``, uma explicação
|
||||
sintética não pode mudar nenhum dos 50 desfechos.
|
||||
|
||||
O desempate por data só age quando AMBOS os lados têm data legível. Como os
|
||||
casos desta suíte não carregam `period`, o resultado tem de ser idêntico ao
|
||||
run sem explicação — é o que prova que **ausência de data não autoriza
|
||||
escolha por eliminação**."""
|
||||
sintetica = (
|
||||
"Houve a variação dos seguintes serviços de valor adicionado:\n"
|
||||
f"* Cobrança {case.var_desc} no valor de R$ {case.var_value} no dia 01/01/26."
|
||||
)
|
||||
|
||||
sem = _run(case) or []
|
||||
com = (
|
||||
varied_avulso_items(
|
||||
invoice_variation=_variation(case.var_desc, case.var_value),
|
||||
invoice_detail=_detail(case.invoice),
|
||||
resolver=InvoiceResolver(),
|
||||
by_charge=False,
|
||||
explanation_text=sintetica,
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
assert [it.canonical_name for it in sem] == [it.canonical_name for it in com], (
|
||||
f"{case.case_id}: a explicação mudou o desfecho sem haver data no detalhe"
|
||||
)
|
||||
132
tests/migration/test_original_workflow_cases.py
Normal file
132
tests/migration/test_original_workflow_cases.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.workflows import FileWorkflowRepository, WorkflowActionRegistry, WorkflowRuntime
|
||||
from agent_framework.workflows.models import WorkflowDefinition
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CASES = ROOT / "tests" / "migration" / "data" / "workflow_yaml_cases"
|
||||
|
||||
|
||||
def _load_cases():
|
||||
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(CASES.glob("*.json"))]
|
||||
|
||||
|
||||
def _contains_subset(expected: Any, observed: Any) -> bool:
|
||||
if isinstance(expected, dict):
|
||||
return isinstance(observed, dict) and all(
|
||||
key in observed and _contains_subset(value, observed[key]) for key, value in expected.items()
|
||||
)
|
||||
if isinstance(expected, list):
|
||||
return isinstance(observed, list) and len(expected) <= len(observed) and all(
|
||||
_contains_subset(value, observed[index]) for index, value in enumerate(expected)
|
||||
)
|
||||
return expected == observed
|
||||
|
||||
|
||||
class _DefinitionRepository:
|
||||
def __init__(self, definition):
|
||||
self.definition = definition
|
||||
|
||||
def get_active(self, name):
|
||||
assert name == self.definition.name
|
||||
return self.definition
|
||||
|
||||
def get_version(self, name, version):
|
||||
assert (name, version) == (self.definition.name, self.definition.version)
|
||||
return self.definition
|
||||
|
||||
|
||||
def _normalized_expected_nodes(case):
|
||||
nodes = list((case.get("expect") or {}).get("trace_nodes") or [])
|
||||
# Runtime antigo repetia a action imediatamente anterior ao pause. O runtime
|
||||
# novo deliberadamente não a repete para impedir side effects duplicados.
|
||||
normalized = []
|
||||
for node in nodes:
|
||||
if normalized and normalized[-1] == node:
|
||||
continue
|
||||
normalized.append(node)
|
||||
return normalized
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("case", _load_cases(), ids=lambda c: c["id"])
|
||||
async def test_original_workflow_case_on_framework_runtime(case):
|
||||
original = FileWorkflowRepository(ROOT / "workflows").get_version(case["workflow"], int(case["version"]))
|
||||
raw = original.model_dump(by_alias=True)
|
||||
registry = WorkflowActionRegistry()
|
||||
calls = []
|
||||
|
||||
# No teste cada node recebe uma action exclusiva. Isso permite reproduzir
|
||||
# exatamente os outputs configurados no caso JSON, mesmo quando dois nodes
|
||||
# compartilham a mesma action de produção.
|
||||
for node in raw["nodes"]:
|
||||
node_id = node["id"]
|
||||
real_action = node["action"]
|
||||
test_action = f"__case__{node_id}"
|
||||
node["action"] = test_action
|
||||
|
||||
async def handler(params, state, *, _node=node_id, _action=real_action):
|
||||
cfgs = dict(case.get("actions") or {})
|
||||
configured = cfgs.get(_node) if isinstance(cfgs.get(_node), dict) else cfgs.get(_action)
|
||||
configured = configured if isinstance(configured, dict) else {}
|
||||
calls.append({"node_id": _node, "action": _action, "params": deepcopy(params)})
|
||||
if configured.get("success") is False:
|
||||
raise RuntimeError(str(configured.get("error") or "falha configurada"))
|
||||
return deepcopy(configured.get("output") or {"success": True})
|
||||
|
||||
registry.register(test_action, handler)
|
||||
|
||||
definition = WorkflowDefinition.model_validate(raw)
|
||||
runtime = WorkflowRuntime(_DefinitionRepository(definition), actions=registry, checkpointer=None, allow_deterministic_fallback=True)
|
||||
first = await runtime.arun(definition.name, deepcopy(case.get("input") or {}), version=definition.version)
|
||||
|
||||
start_expect = case.get("expect_start")
|
||||
if isinstance(start_expect, dict):
|
||||
if start_expect.get("status") == "WAITING_INPUT":
|
||||
assert first.status == "PAUSED"
|
||||
if "paused_at" in start_expect:
|
||||
assert first.pause.get("node") == start_expect["paused_at"]
|
||||
expected_input = first.pause.get("expected_input") or {}
|
||||
if "expected_input_key" in start_expect:
|
||||
assert expected_input.get("key") == start_expect["expected_input_key"]
|
||||
if "allowed_values" in start_expect:
|
||||
assert expected_input.get("allowed_values") == start_expect["allowed_values"]
|
||||
|
||||
final = first
|
||||
resume = case.get("resume")
|
||||
if isinstance(resume, dict):
|
||||
final = await runtime.aresume(definition.name, first.execution_id, deepcopy(resume), version=definition.version)
|
||||
|
||||
expect = dict(case.get("expect") or {})
|
||||
assert final.status == expect.get("status")
|
||||
if "last_node" in expect:
|
||||
assert final.state.get("current_node") == expect["last_node"]
|
||||
|
||||
# Data do runtime antigo era a união dos outputs. ``vars`` representa essa
|
||||
# informação no runtime novo e é a fonte para referências $.vars.* do YAML.
|
||||
flattened = {}
|
||||
for value in (final.state.get("vars") or {}).values():
|
||||
if isinstance(value, dict):
|
||||
flattened.update(value)
|
||||
if "data_subset" in expect:
|
||||
assert _contains_subset(expect["data_subset"], flattened)
|
||||
|
||||
observed_nodes = [item.get("node") for item in final.trace if item.get("action") != "pause_resume"]
|
||||
if "trace_nodes" in expect:
|
||||
active_node_ids = {node.id for node in definition.nodes}
|
||||
expected_nodes = [node for node in _normalized_expected_nodes(case) if node in active_node_ids]
|
||||
assert observed_nodes == expected_nodes
|
||||
|
||||
for expected in expect.get("calls", []):
|
||||
found = [c for c in calls if c["node_id"] == expected.get("node_id") and ("action" not in expected or c["action"] == expected["action"])]
|
||||
assert found, expected
|
||||
subset = expected.get("params_subset")
|
||||
if subset is not None:
|
||||
assert any(_contains_subset(subset, c["params"]) for c in found)
|
||||
113
tests/migration/test_pro_rata_full_parity.py
Normal file
113
tests/migration/test_pro_rata_full_parity.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.domain.contas.pro_rata_rules import calculate_refund, payment_message
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
def planos(*, days_other=25, liquid=67.99):
|
||||
return [
|
||||
{"desc":"TIM Controle Smart 8 0 (096/POS/SMP)","days":None,"valor_bruto":92.99,"valor_final":liquid,"msisdn":"44998254771","is_controle":True},
|
||||
{"desc":"TIM Black A 8 0 (114/POS/SMP)","days":days_other,"valor_bruto":158.30,"valor_final":83.38,"msisdn":"44998254771","is_controle":False},
|
||||
]
|
||||
|
||||
|
||||
def invoice(danfe=None):
|
||||
return {
|
||||
"Fatura Resumo":[{"desc":"PERÍODO","period":"19/09 a 18/10"},{"desc":"EMISSÃO","emissao":"29/10/2025"}],
|
||||
"DANFE-COM": danfe or {"Planos":{"TIM Controle Smart 8 0":[
|
||||
{"desc":"TIM Controle Smart 8 0","valor_final":40.0},
|
||||
{"desc":"Aya Books Premium","valor_final":15.0},
|
||||
{"desc":"Mulheres Positivas","valor_final":10.0},
|
||||
]}},
|
||||
}
|
||||
|
||||
|
||||
def test_calculo_pro_rata_usa_liquido_periodo_e_danfe():
|
||||
out=calculate_refund(planos=planos(), invoice_detail=invoice())
|
||||
assert out["dias_ciclo"] == 30
|
||||
assert out["dias_controle"] == 5
|
||||
assert out["valor_liquido"] == "67.99"
|
||||
assert out["valor_devolver"] == "56.66"
|
||||
assert out["invoice_amount"] == "56.66"
|
||||
assert [x["itemName"] for x in out["items"]] == ["TIM Controle Smart 8 0","Aya Books Premium","Mulheres Positivas"]
|
||||
assert sum(float(x["validatedAmount"]) for x in out["items"]) == 56.66
|
||||
|
||||
|
||||
def test_calculo_pro_rata_outro_plano_30_dias_considera_um_dia_controle():
|
||||
out=calculate_refund(planos=planos(days_other=30), invoice_detail=invoice({"Planos":{"TIM Controle Smart 8 0":[{"desc":"TIM Controle Smart 8 0","valor_final":67.99}]}}))
|
||||
assert out["dias_controle"] == 1
|
||||
assert float(out["valor_devolver"]) > 65
|
||||
|
||||
|
||||
def test_calculo_falha_sem_periodo():
|
||||
data=invoice(); data["Fatura Resumo"]=[]
|
||||
try:
|
||||
calculate_refund(planos=planos(), invoice_detail=data)
|
||||
except ValueError as exc:
|
||||
assert "Periodo" in str(exc)
|
||||
else:
|
||||
raise AssertionError("esperava falha")
|
||||
|
||||
|
||||
def test_calculo_falha_quando_danfe_nao_cobre_credito():
|
||||
data=invoice({"Planos":{"TIM Controle Smart 8 0":[{"desc":"TIM Controle Smart 8 0","valor_final":1.0}]}})
|
||||
try:
|
||||
calculate_refund(planos=planos(), invoice_detail=data)
|
||||
except ValueError as exc:
|
||||
assert "insuficientes" in str(exc)
|
||||
else:
|
||||
raise AssertionError("esperava falha")
|
||||
|
||||
|
||||
def test_mensagem_pagamento_sms_soma_items_e_protocolo():
|
||||
msg=payment_message({
|
||||
"items":[{"itemName":"Plano X","validatedAmount":10},{"itemName":"Extra","validatedAmount":4.99}],
|
||||
"format_text":"sms","barcode":"123","protocolo_id":"9988"
|
||||
})
|
||||
assert "R$ 14,99" in msg
|
||||
assert "codigo de barras atualizado" in msg
|
||||
assert "9988" in msg
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self): self.payload=None
|
||||
def contestar(self,payload):
|
||||
self.payload=payload
|
||||
return {"protocol_number":"PRT-1","sms_sent":True,"barcode":"3419","contestation_id":"C-1"}
|
||||
|
||||
class Service:
|
||||
def __init__(self): self.client=Client()
|
||||
def buscar_fatura_detalhada(self, **kwargs): return {"parsed_content":invoice()}
|
||||
|
||||
|
||||
def test_action_definir_devolucao_busca_danfe_quando_ausente():
|
||||
svc=Service(); reg=build_contas_workflow_actions(svc)
|
||||
action=reg.get("definir_devolucao_ajuste_pro_rata")
|
||||
out=action({"planos":planos(),"invoice_id":"INV","customer_id":"C","msisdn":"44998254771"},{"input":{}})
|
||||
assert out["success"] is True
|
||||
assert out["invoice_amount"] == "56.66"
|
||||
assert out["items"]
|
||||
|
||||
|
||||
def test_action_contestacao_pro_rata_usa_items_e_totais():
|
||||
svc=Service(); reg=build_contas_workflow_actions(svc)
|
||||
action=reg.get("executar_contestacao_plano_controle")
|
||||
devolucao={"items":[{"itemName":"Plano X","itemType":"PRO_RATA","claimedAmount":20.0,"validatedAmount":12.5}],"invoice_amount":"12.50","invoice_amount_open":"12.50"}
|
||||
out=action({"devolucao":devolucao,"msisdn":"44998254771","customer_id":"C1","invoice_id":"I1","cpf":"123.456.789-00"},{"input":{}})
|
||||
assert out["success"] is True
|
||||
assert svc.client.payload["tipo_atendimento"] == "pro_rata"
|
||||
assert svc.client.payload["skip_invoice_item_validation"] is True
|
||||
assert svc.client.payload["invoiceAmount"] == "12.50"
|
||||
assert svc.client.payload["socialSecNo"] == "12345678900"
|
||||
assert out["devolucao"]["format_text"] == "sms"
|
||||
|
||||
|
||||
def test_orientar_pagamento_mantem_composicao_no_llm_do_framework():
|
||||
svc=Service(); reg=build_contas_workflow_actions(svc)
|
||||
action=reg.get("orientar_pagamento_pro_rata")
|
||||
out=action({"devolucao":{"items":[{"itemName":"Plano X","validatedAmount":10}],"format_text":"conta_futura"}},{"input":{}})
|
||||
assert "R$ 10,00" in out["mensagem"]
|
||||
assert out["requires_llm_composition"] is True
|
||||
assert "fonte de verdade" in out["response_instruction"]
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent_framework.runtime import AgentRuntimeMixin
|
||||
from app.domain.contas.informational_context import infer_informational_context
|
||||
|
||||
|
||||
def test_informational_rag_avulso_preserva_contexto_para_finalizacao():
|
||||
detail={"119": {"SVA Detalhe Total": [{"desc":"Aluguel de Filme 3","value":19.9}]}}
|
||||
out=infer_informational_context("Aluguel de Filme 3 o que é", detail)
|
||||
assert out["informational_service_names"]==["Aluguel de Filme 3"]
|
||||
assert out["informational_vas_types"]==["avulso"]
|
||||
assert out["informational_rag_vas_types"]==["avulso"]
|
||||
|
||||
|
||||
def test_informational_rag_estrategico_sem_invoice_detail():
|
||||
out=infer_informational_context("Como funciona Paramount+?", None)
|
||||
assert out["informational_vas_types"]==["estrategico"]
|
||||
|
||||
|
||||
def test_framework_rag_falha_tecnica_nao_quebra_fluxo():
|
||||
class Rag:
|
||||
async def retrieve(self,*a,**k): raise RuntimeError('falha oracle')
|
||||
class X(AgentRuntimeMixin): pass
|
||||
x=X(); x.rag_service=Rag(); x.settings=SimpleNamespace(SKIP_RAG_WHEN_MCP_SUFFICIENT=False, ENABLE_RAG_QUERY_REWRITE=False)
|
||||
x.get_runtime_context=lambda state: SimpleNamespace(context={}, business_context={}, sanitized_input='regra de desconto')
|
||||
ctx,meta=asyncio.run(x._retrieve_rag_context({'user_text':'regra de desconto','mcp_results':[]}))
|
||||
assert ctx==''
|
||||
assert meta['technical_error_in_rag'] is True
|
||||
assert meta['error']=='falha oracle'
|
||||
117
tests/migration/test_remaining_command_contracts.py
Normal file
117
tests/migration/test_remaining_command_contracts.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.domain.contas.client import TimApiClient
|
||||
|
||||
|
||||
def _real_client() -> TimApiClient:
|
||||
c = TimApiClient()
|
||||
c.mock = False
|
||||
return c
|
||||
|
||||
|
||||
def test_vas_history_preserves_original_header_contract(monkeypatch):
|
||||
monkeypatch.setenv("TIM_VAS_HISTORY_URL", "http://mock/servicesHistory")
|
||||
monkeypatch.setenv("TIM_VAS_HISTORY_AUTH", "Basic token")
|
||||
monkeypatch.setenv("TIM_VAS_HISTORY_CLIENT_ID", "TIMX")
|
||||
monkeypatch.setenv("TIM_VAS_HISTORY_MESSAGE_ID", "msg-1")
|
||||
c = _real_client()
|
||||
with patch.object(c, "request", return_value={}) as req:
|
||||
c.historico_vas("12981114135")
|
||||
args, kwargs = req.call_args
|
||||
assert args[:2] == ("GET", "http://mock/servicesHistory?msisdn=12981114135")
|
||||
assert kwargs["headers"]["clientId"] == "TIMX"
|
||||
assert kwargs["headers"]["messageId"] == "msg-1"
|
||||
assert kwargs["headers"]["Authorization"] == "Basic token"
|
||||
|
||||
|
||||
def test_contrato_uses_clientId_not_client_id(monkeypatch):
|
||||
monkeypatch.setenv("TIM_CONTRATO_URL", "http://test")
|
||||
monkeypatch.setenv("TIM_CONTRATO_AUTH", "Basic test")
|
||||
monkeypatch.setenv("TIM_CONTRATO_CLIENT_ID", "TEST")
|
||||
c = _real_client()
|
||||
with patch.object(c, "request", return_value={}) as req:
|
||||
c.contrato("11988888888")
|
||||
args, kwargs = req.call_args
|
||||
assert args[:2] == ("GET", "http://test/11988888888")
|
||||
assert kwargs["headers"]["clientId"] == "TEST"
|
||||
assert "client_id" not in kwargs["headers"]
|
||||
assert kwargs["headers"]["Authorization"] == "Basic test"
|
||||
|
||||
|
||||
def test_profile_full_uses_original_ClientID_header(monkeypatch):
|
||||
monkeypatch.setenv("TIM_PROFILE_FULL_URL", "http://test/access/v1/info/{msisdn}")
|
||||
monkeypatch.setenv("TIM_PROFILE_FULL_AUTH", "Basic token")
|
||||
monkeypatch.setenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")
|
||||
c = _real_client()
|
||||
with patch.object(c, "request", return_value={}) as req:
|
||||
c.profile_full("5511999999999")
|
||||
args, kwargs = req.call_args
|
||||
assert args[:2] == ("GET", "http://test/access/v1/info/5511999999999")
|
||||
assert kwargs["headers"]["ClientID"] == "AIAGENTCR"
|
||||
assert kwargs["headers"]["Authorization"] == "Basic token"
|
||||
|
||||
|
||||
def test_cancelamento_always_sends_non_empty_message_id(monkeypatch):
|
||||
monkeypatch.setenv("TIM_CANCELAMENTO_URL", "http://test/cancel")
|
||||
c = _real_client()
|
||||
service = {"appId": "14534", "cspId": "740"}
|
||||
with patch.object(c, "request", return_value={"ok": True}) as req:
|
||||
c.cancelar_vas("11943896592", service, protocol="PROT-789")
|
||||
_args, kwargs = req.call_args
|
||||
assert kwargs["payload"]["channel"] == "AIAGENTCR"
|
||||
assert kwargs["payload"]["interactionProtocol"] == "PROT-789"
|
||||
assert kwargs["headers"]["messageId"]
|
||||
|
||||
|
||||
def test_sms_preserva_sender_name_original(monkeypatch):
|
||||
from app.domain.contas.client import TimApiClient
|
||||
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "false")
|
||||
monkeypatch.setenv("TIM_GATEWAY_MODE", "real")
|
||||
monkeypatch.setenv("TIM_SMS_URL", "http://test/sms")
|
||||
monkeypatch.setenv("TIM_SMS_AUTH", "Basic static")
|
||||
monkeypatch.setenv("TIM_SMS_CLIENT_ID", "CHAT")
|
||||
monkeypatch.setenv("TIM_SMS_SENDER_ADDRESS", "324")
|
||||
monkeypatch.delenv("TIM_SMS_SENDER_NAME", raising=False)
|
||||
client = TimApiClient()
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured.update({"method": method, "url": url, **kwargs})
|
||||
return {"resourceURL": "http://sms/1"}
|
||||
|
||||
monkeypatch.setattr(client, "request", fake_request)
|
||||
client.sms("11999999999", "mensagem")
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["payload"]["senderAddress"] == "324"
|
||||
assert captured["payload"]["senderName"] == "TIM Brasil"
|
||||
assert captured["payload"]["longURL"] == "mensagem"
|
||||
assert captured["headers"]["clientId"] == "CHAT"
|
||||
assert captured["headers"]["Authorization"] == "Basic static"
|
||||
|
||||
|
||||
def test_service_request_status_message_id_prioriza_call_id(monkeypatch):
|
||||
from app.domain.contas.client import TimApiClient
|
||||
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "false")
|
||||
monkeypatch.setenv("TIM_GATEWAY_MODE", "real")
|
||||
monkeypatch.setenv("TIM_SERVICE_REQUEST_STATUS_URL", "http://test/sr")
|
||||
client = TimApiClient()
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured.update({"method": method, "url": url, **kwargs})
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr(client, "request", fake_request)
|
||||
client.status_sr({
|
||||
"channel": "AIAGENTCR",
|
||||
"protocolNumber": "PRT-123",
|
||||
"ura_call_id": "CALL-123",
|
||||
"session_id": "sess-1",
|
||||
})
|
||||
assert captured["payload"]["serviceRequest"]["protocolNumber"] == "PRT-123"
|
||||
assert captured["headers"]["messageId"] == "CALL-123"
|
||||
69
tests/migration/test_tim_contract_parity.py
Normal file
69
tests/migration/test_tim_contract_parity.py
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
|
||||
def test_protocol_v2_contract(monkeypatch):
|
||||
from app.domain.contas.client import TimApiClient
|
||||
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "false")
|
||||
monkeypatch.setenv("TIM_GATEWAY_MODE", "real")
|
||||
monkeypatch.setenv("TIM_PROTOCOL_URL", "http://test/protocol")
|
||||
client = TimApiClient()
|
||||
captured = {}
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
captured.update(method=method, url=url, **kwargs)
|
||||
return {"interactionProtocol": "PRT-999"}
|
||||
|
||||
monkeypatch.setattr(client, "request", fake_request)
|
||||
result = client.abrir_protocolo({
|
||||
"msisdn": "11999998888",
|
||||
"socialSecNo": "12345678900",
|
||||
"customerId": "C1",
|
||||
"customerName": "Cliente Teste",
|
||||
"customerEmail": "teste@example.com",
|
||||
"customerPhone1": "11999999999",
|
||||
"accessType": "Pós-Pago",
|
||||
"serviceRequestUserId": "USR123",
|
||||
"reason1": "Vendas", "reason2": "Cancelamento", "reason3": "Retencao",
|
||||
"requestStatus": "Aberto", "status": "OPENED",
|
||||
"serviceRequestNotes": "Obs",
|
||||
"interactionCallId": "CALL-1",
|
||||
"interactionReasonId": "R1", "interactionAmount": "1", "interactionRequestSla": "SLA-1",
|
||||
"directionContact": "FROM-CLIENT", "clientId": "BFFDIGITAL", "messageId": "msg-9",
|
||||
"authorization_oam": "Bearer oam", "cn_field": "CN", "type_field": "TYPE",
|
||||
})
|
||||
assert result["interactionProtocol"] == "PRT-999"
|
||||
assert captured["method"] == "POST"
|
||||
body = captured["payload"]
|
||||
assert body["assetId"] == "11999998888"
|
||||
assert body["channel"] == "AIAGENTCR"
|
||||
assert body["serviceRequest"]["reason2"] == "Cancelamento"
|
||||
assert body["interaction"]["crmSource"] == "Siebel Pós"
|
||||
assert body["interaction"]["source"] == "AIAGENTCR"
|
||||
assert body["interaction"]["requestFlag"] is False
|
||||
assert captured["headers"]["messageId"] == "msg-9"
|
||||
assert captured["headers"]["Authorizationoam"] == "Bearer oam"
|
||||
|
||||
|
||||
def test_rct_retry_policy_preserves_attempt_semantics():
|
||||
from app.domain.contas.rct_policy import rct_tags_for_attempt
|
||||
assert rct_tags_for_attempt("cancela_vas", 1, success=False) == ("RCT.002",)
|
||||
assert rct_tags_for_attempt("cancela_vas", 2, success=True) == ("RCT.003",)
|
||||
assert rct_tags_for_attempt("sgr_codbar", 1, success=False) == ("RCT.014",)
|
||||
assert rct_tags_for_attempt("sgr_codbar", 2, success=True) == ("RCT.015",)
|
||||
assert rct_tags_for_attempt("contestacao", 3, success=True) == ("RCT.011",)
|
||||
|
||||
|
||||
def test_billing_analysis_anexa_transport_metadata_de_tentativas(monkeypatch):
|
||||
from app.domain.contas.client import TimApiClient
|
||||
client = TimApiClient(); client.mock = False
|
||||
monkeypatch.setenv("TIM_DIVERGENCIA_URL", "http://tim/billingAnalysis")
|
||||
def fake_request(method, url, **kwargs):
|
||||
kwargs["attempt_log"].extend([
|
||||
{"attempt": 1, "success": False, "status_code": 500, "latency_ms": 10, "error": "x"},
|
||||
{"attempt": 2, "success": True, "status_code": 200, "latency_ms": 8},
|
||||
])
|
||||
return {"invoiceExplanation": "ok"}
|
||||
monkeypatch.setattr(client, "request", fake_request)
|
||||
result = client.billing_analysis("11999999999")
|
||||
assert result["_transport"]["rct_operation"] == "base_conhecimento"
|
||||
assert [a["attempt"] for a in result["_transport"]["attempts"]] == [1, 2]
|
||||
87
tests/migration/test_tim_contracts_and_idempotency.py
Normal file
87
tests/migration/test_tim_contracts_and_idempotency.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.idempotency import InMemoryIdempotencyStore
|
||||
from app.domain.contas.client import TimApiClient
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
from app.domain.contas.integrations.secure_pdf_crypto import decrypt_secure_pdf_value
|
||||
|
||||
|
||||
def _real_client(monkeypatch):
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "false")
|
||||
monkeypatch.setenv("TIM_GATEWAY_MODE", "real")
|
||||
return TimApiClient()
|
||||
|
||||
|
||||
def test_complete_invoices_preserves_clientid_contract(monkeypatch):
|
||||
client = _real_client(monkeypatch)
|
||||
monkeypatch.setenv("TIM_COMPLETE_INVOICES_URL", "http://tim/complete")
|
||||
calls = []
|
||||
monkeypatch.setattr(client, "request", lambda *a, **kw: calls.append((a, kw)) or {})
|
||||
client.consultar_faturas("5511999999999")
|
||||
args, kw = calls[0]
|
||||
assert args[:2] == ("POST", "http://tim/complete")
|
||||
assert kw["payload"] == {"msisdn": "5511999999999"}
|
||||
assert kw["headers"]["ClientID"] == "AIAGENTCR"
|
||||
|
||||
|
||||
def test_query_vas_preserves_original_client_id_and_url(monkeypatch):
|
||||
client = _real_client(monkeypatch)
|
||||
monkeypatch.setenv("TIM_URL_CONSULTA_VAS", "http://tim/access/v1/{msisdn}/partnerServices")
|
||||
calls = []
|
||||
monkeypatch.setattr(client, "request", lambda *a, **kw: calls.append((a, kw)) or {})
|
||||
client.consultar_vas("5511999999999")
|
||||
args, kw = calls[0]
|
||||
assert args == ("GET", "http://tim/access/v1/5511999999999/partnerServices")
|
||||
assert kw["headers"]["clientId"] == "AIAAGENTCR"
|
||||
|
||||
|
||||
def test_bill_pdf_and_secure_pdf_are_distinct_contracts(monkeypatch):
|
||||
client = _real_client(monkeypatch)
|
||||
monkeypatch.setenv("TIM_URL_INVOICE_RECOVER", "http://tim/invoice")
|
||||
calls = []
|
||||
monkeypatch.setattr(client, "request", lambda *a, **kw: calls.append((a, kw)) or {"parsed_content": {"ok": True}})
|
||||
|
||||
detailed = client.bill_pdf("119", "INV-1", "CUS-1")
|
||||
assert detailed["parsed_content"] == {"ok": True}
|
||||
args, kw = calls.pop(0)
|
||||
assert args == ("POST", "http://tim/invoice")
|
||||
assert decrypt_secure_pdf_value(kw["payload"]["invoiceId"]) == "INV-1"
|
||||
assert decrypt_secure_pdf_value(kw["payload"]["customerId"]) == "CUS-1"
|
||||
assert kw["payload"]["invoiceType"] == "DETALHADA"
|
||||
assert kw["headers"]["Accept"] == "application/pdf"
|
||||
|
||||
client.secure_pdf("119", "INV-1", "CUS-1")
|
||||
args, kw = calls.pop(0)
|
||||
assert args == ("GET", "http://tim/invoice")
|
||||
assert decrypt_secure_pdf_value(kw["params"]["invoiceId"]) == "INV-1"
|
||||
assert decrypt_secure_pdf_value(kw["params"]["msisdn"]) == "119"
|
||||
assert decrypt_secure_pdf_value(kw["params"]["customerId"]) == "CUS-1"
|
||||
|
||||
|
||||
def test_line_info_extracts_nested_social_sec_no(monkeypatch):
|
||||
client = _real_client(monkeypatch)
|
||||
monkeypatch.setenv("TIM_PROFILE_FULL_URL", "http://tim/info/{msisdn}")
|
||||
monkeypatch.setattr(client, "request", lambda *a, **kw: {"customer": {"socialSecNo": "12345678900"}})
|
||||
result = client.line_info("5511999999999")
|
||||
assert result["social_sec_no"] == "12345678900"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_workflow_action_is_idempotent_without_domain_local_cache(monkeypatch):
|
||||
monkeypatch.setenv("TIM_USE_MOCK_GATEWAY", "true")
|
||||
service = ContasDomainService()
|
||||
store = InMemoryIdempotencyStore(namespace="test")
|
||||
registry = build_contas_workflow_actions(service, idempotency_store=store)
|
||||
action = registry.get("cancelamento_vas_avulso_batch")
|
||||
params = {
|
||||
"idempotency_key": "interaction-1",
|
||||
"items": [{"msisdn": "11999999999", "name": "TIM Fashion Mensal"}],
|
||||
}
|
||||
first = await action(params, {"input": {}})
|
||||
second = await action(params, {"input": {}})
|
||||
assert first["success"] is True
|
||||
assert second["success"] is True
|
||||
assert second["results"][0]["idempotent_replay"] is True
|
||||
266
tests/migration/test_tim_event_metadata_parity.py
Normal file
266
tests/migration/test_tim_event_metadata_parity.py
Normal file
@@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from agent_framework.analytics.tim_payload_mapper import map_analytics_event_to_tim_flat_payload
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
def _event(result: dict, code: str) -> dict:
|
||||
return next(x for x in result.get("business_events", []) if x.get("code") == code)
|
||||
|
||||
|
||||
def test_contestacao_vaa_metadata_preserva_contexto_tim_original():
|
||||
class Client:
|
||||
def billing_analysis(self, msisdn):
|
||||
return {"currentInvoice": [{"desc": "Serviços de valor adicionado", "items": [{"desc": "VAS Musica", "value": 29.9, "classe": "avulso"}]}]}
|
||||
|
||||
def contestar(self, payload):
|
||||
return {"barcode": "8469", "itemsResponse": []}
|
||||
|
||||
class Service:
|
||||
client = Client()
|
||||
|
||||
action = build_contas_workflow_actions(Service()).get("abrir_contestacao_cliente")
|
||||
state = {
|
||||
"input": {
|
||||
"session_id": "sess-1",
|
||||
"message_id": "msg-1",
|
||||
"ura_call_id": "CALL-123",
|
||||
"channel_id": "URA",
|
||||
}
|
||||
}
|
||||
result = action(
|
||||
{
|
||||
"msisdn": "11999998888",
|
||||
"protocolo_id": "PRT-11",
|
||||
"invoice_id": "3000135012",
|
||||
"servico": "VAS Musica",
|
||||
"valor": 29.9,
|
||||
},
|
||||
state,
|
||||
)
|
||||
payload = _event(result, "VAA.006")["payload"]
|
||||
assert payload["agentId"] == "contas"
|
||||
assert payload["channelId"] == "URA"
|
||||
assert payload["gsm"] == "11999998888"
|
||||
assert payload["uraCallId"] == "CALL-123"
|
||||
assert payload["sessionId"] == "sess-1"
|
||||
assert payload["messageId"] == "msg-1"
|
||||
assert payload["agentProtocolId"] == "PRT-11"
|
||||
assert payload["adjustedProtocol"] == "PRT-11"
|
||||
assert payload["billingId"] == "3000135012"
|
||||
assert payload["adjustedItems"] == ["VAS Musica"]
|
||||
assert payload["adjustedItemsAmount"] == ["29.9"]
|
||||
assert json.loads(payload["agentSpecificData"]) == {
|
||||
"adjustedItems": ["VAS Musica"],
|
||||
"adjustedItemsAmount": ["29.9"],
|
||||
}
|
||||
|
||||
|
||||
def test_contestacao_vaa007_falha_preserva_protocolo_e_itens():
|
||||
class Client:
|
||||
def billing_analysis(self, msisdn):
|
||||
return {"currentInvoice": [{"desc": "Serviços de valor adicionado", "items": [{"desc": "VAS Musica", "value": 29.9, "classe": "avulso"}]}]}
|
||||
|
||||
def contestar(self, payload):
|
||||
raise RuntimeError("falha provider")
|
||||
|
||||
class Service:
|
||||
client = Client()
|
||||
|
||||
action = build_contas_workflow_actions(Service()).get("abrir_contestacao_cliente")
|
||||
result = action(
|
||||
{"msisdn": "119", "protocolo_id": "PRT-11", "invoice_id": "INV-1", "servico": "VAS Musica", "valor": 29.9},
|
||||
{"input": {"channel_id": "URA", "ura_call_id": "CALL-1"}},
|
||||
)
|
||||
payload = _event(result, "VAA.007")["payload"]
|
||||
assert payload["agentProtocolId"] == "PRT-11"
|
||||
assert payload["adjustedProtocol"] == "PRT-11"
|
||||
assert payload["adjustedItems"] == ["VAS Musica"]
|
||||
assert payload["adjustedItemsAmount"] == ["29.9"]
|
||||
|
||||
|
||||
def test_tim_payload_mapper_aceita_agent_specific_data_json_string_historico():
|
||||
mapped = map_analytics_event_to_tim_flat_payload(
|
||||
"VAA.006",
|
||||
{
|
||||
"eventType": "VAA.006",
|
||||
"payload": {
|
||||
"tag": "VAA.006",
|
||||
"agentProtocolId": "P1",
|
||||
"agentSpecificData": '{"billingId":"3000131180","item":"VAS"}',
|
||||
},
|
||||
},
|
||||
)
|
||||
assert mapped["agentProtocolId"] == "P1"
|
||||
assert mapped["agentSpecificData"] == {"billingId": "3000131180", "item": "VAS"}
|
||||
|
||||
|
||||
def test_finalizacao_cvn010_preserva_protocolos_e_contexto_ura():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class Client:
|
||||
def abrir_protocolo(self, payload):
|
||||
return {"interactionProtocol": "INFO-123"}
|
||||
def status_sr(self, payload):
|
||||
return {"ok": True}
|
||||
|
||||
action = build_contas_workflow_actions(ContasDomainService(client=Client())).get("finalizar_atendimento_action")
|
||||
result = action(
|
||||
{
|
||||
"status": "resolvido",
|
||||
"summary": "ok",
|
||||
"msisdn": "11999999999",
|
||||
"social_sec_no": "123",
|
||||
"session_id": "sess-final",
|
||||
"message_id": "msg-final",
|
||||
"ura_call_id": "CALL-FINAL",
|
||||
"ura_protocol_id": "URA-123",
|
||||
"channel_id": "URA",
|
||||
"invoice_id": "3000131180",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Explicação.",
|
||||
},
|
||||
{"input": {}},
|
||||
)
|
||||
payload = _event(result, "CVN.010")["payload"]
|
||||
assert payload["agentProtocolId"] == "INFO-123"
|
||||
assert payload["adjustedProtocol"] == "INFO-123"
|
||||
assert payload["uraProtocolId"] == "URA-123"
|
||||
assert payload["uraCallId"] == "CALL-FINAL"
|
||||
assert payload["channelId"] == "URA"
|
||||
assert payload["billingId"] == "3000131180"
|
||||
assert json.loads(payload["agentSpecificData"]) == {"billingId": "3000131180"}
|
||||
|
||||
|
||||
def test_vas_estrategico_veb_preserva_turno_e_contexto_ura():
|
||||
class Client:
|
||||
def abrir_protocolo(self, payload):
|
||||
return {"interactionProtocol": "VEB-123"}
|
||||
|
||||
class Service:
|
||||
client = Client()
|
||||
|
||||
action = build_contas_workflow_actions(Service()).get("registrar_atendimento_vas_estrategico")
|
||||
result = action(
|
||||
{
|
||||
"linhas": [{"msisdn": "5511999999999"}],
|
||||
"mensagem_base": "Informação sobre cancelamento.",
|
||||
},
|
||||
{
|
||||
"input": {
|
||||
"resposta_usuario": "NAO",
|
||||
"customer_message": "Não quero manter o serviço.",
|
||||
"ura_call_id": "155344608",
|
||||
"channel_id": "ura",
|
||||
"message_id": "msg-005",
|
||||
}
|
||||
},
|
||||
)
|
||||
codes = [x["code"] for x in result["business_events"]]
|
||||
assert codes == ["VEB.004", "VEB.006", "VEB.007"]
|
||||
payload = _event(result, "VEB.004")["payload"]
|
||||
assert payload["uraCallId"] == "155344608"
|
||||
assert payload["channelId"] == "ura"
|
||||
assert payload["messageId"] == "msg-005"
|
||||
assert payload["customerMessage"] == "NAO"
|
||||
assert payload["llmResponse"] == "Informação sobre cancelamento."
|
||||
|
||||
|
||||
def test_finalizacao_mpi006_e_sad001_preservam_metadata_conversacional():
|
||||
from app.domain.contas.service import ContasDomainService
|
||||
|
||||
class Client:
|
||||
def abrir_protocolo(self, payload):
|
||||
return {"interactionProtocol": "INFO-321"}
|
||||
def status_sr(self, payload):
|
||||
return {"ok": True}
|
||||
|
||||
action = build_contas_workflow_actions(ContasDomainService(client=Client())).get("finalizar_atendimento_action")
|
||||
result = action(
|
||||
{
|
||||
"status": "resolvido",
|
||||
"summary": "Cliente entendeu.",
|
||||
"msisdn": "11999999999",
|
||||
"invoice_id": "3000131180",
|
||||
"business_workflows_executed": ["invoice_explanation"],
|
||||
"invoice_explanation_base": "Explicação dos valores da fatura",
|
||||
"customer_message": "Sim, entendi",
|
||||
"llm_response": "Perfeito, fico feliz em ajudar.",
|
||||
"message_id": "msg-final",
|
||||
"session_id": "sess-final",
|
||||
"channel_id": "URA",
|
||||
"ura_call_id": "CALL-FINAL",
|
||||
"session_end_at": "2026-06-11T12:00:00Z",
|
||||
},
|
||||
{"input": {}},
|
||||
)
|
||||
mpi = _event(result, "MPI.006")["payload"]
|
||||
assert mpi["customerMessage"] == "Sim, entendi"
|
||||
assert mpi["llmResponse"] == "Perfeito, fico feliz em ajudar."
|
||||
assert mpi["billingId"] == "3000131180"
|
||||
assert mpi["messageId"] == "msg-final"
|
||||
assert mpi["sessionId"] == "sess-final"
|
||||
|
||||
sad = _event(result, "SAD.001")["payload"]
|
||||
assert sad["llmResponse"] == "Perfeito, fico feliz em ajudar."
|
||||
assert sad["messageId"] == "msg-final"
|
||||
assert sad["sessionEndAt"] == 1781179200000
|
||||
|
||||
|
||||
def test_vas_bundle_nao_defera_protocolo_e_emite_veb005():
|
||||
class Client:
|
||||
def __init__(self): self.calls = 0
|
||||
def abrir_protocolo(self, payload):
|
||||
self.calls += 1
|
||||
return {"interactionProtocol": "SHOULD-NOT-HAPPEN"}
|
||||
|
||||
client = Client()
|
||||
class Service: pass
|
||||
service = Service(); service.client = client
|
||||
action = build_contas_workflow_actions(service).get("registrar_atendimento_vas_estrategico")
|
||||
result = action(
|
||||
{
|
||||
"linhas": [{"msisdn": "5511999999999", "bundle_names": ["TIM Segurança"]}],
|
||||
"mensagem_base": "Bundle incluso no plano.",
|
||||
},
|
||||
{"input": {"resposta_usuario": "NAO", "ura_call_id": "CALL-1", "channel_id": "URA", "message_id": "M1"}},
|
||||
)
|
||||
assert [x["code"] for x in result["business_events"]] == ["VEB.004", "VEB.005", "VEB.007"]
|
||||
assert client.calls == 0
|
||||
assert result["protocol_closed"] is False
|
||||
assert result["protocol_deferred_to_finalization"] is True
|
||||
assert result["suppress_cvn_protocol_ic"] is True
|
||||
assert result["informational_vas_types"] == ["bundle"]
|
||||
|
||||
|
||||
def test_invoice_explanation_mpi005_preserva_turno_e_contexto():
|
||||
class Service: pass
|
||||
action = build_contas_workflow_actions(Service()).get("registrar_atendimento_invoice_explanation")
|
||||
result = action(
|
||||
{"caminho": "NAO", "invoice_id": "INV-9"},
|
||||
{"input": {"customer_message": "Ainda não entendi", "message_id": "M9", "session_id": "S9", "channel_id": "URA", "ura_call_id": "C9"}},
|
||||
)
|
||||
payload = _event(result, "MPI.005")["payload"]
|
||||
assert payload["customerMessage"] == "Ainda não entendi"
|
||||
assert payload["messageId"] == "M9"
|
||||
assert payload["sessionId"] == "S9"
|
||||
assert payload["channelId"] == "URA"
|
||||
assert payload["uraCallId"] == "C9"
|
||||
assert payload["billingId"] == "INV-9"
|
||||
|
||||
|
||||
def test_pro_rata_mpi010_preserva_contexto_do_turno():
|
||||
class Service: pass
|
||||
action = build_contas_workflow_actions(Service()).get("preparar_pro_rata")
|
||||
result = action(
|
||||
{"planos": [], "msisdn": "11999999999"},
|
||||
{"input": {"customer_message": "Por que veio proporcional?", "message_id": "MP1", "session_id": "SP1", "channel_id": "web"}},
|
||||
)
|
||||
payload = _event(result, "MPI.010")["payload"]
|
||||
assert payload["gsm"] == "11999999999"
|
||||
assert payload["customerMessage"] == "Por que veio proporcional?"
|
||||
assert payload["messageId"] == "MP1"
|
||||
assert payload["sessionId"] == "SP1"
|
||||
assert payload["channelId"] == "web"
|
||||
15
tests/migration/test_tool_policy_operation_types.py
Normal file
15
tests/migration/test_tool_policy_operation_types.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework.mcp.tool_policy import ToolPolicyRegistry
|
||||
|
||||
|
||||
def test_contas_tool_policies_accept_all_operation_types():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
registry = ToolPolicyRegistry(str(root / 'config' / 'tool_policies.yaml'))
|
||||
|
||||
assert registry.get('cancelar_vas_avulso').operation_type == 'transactional'
|
||||
assert registry.get('contestar_cobranca').operation_type == 'transactional'
|
||||
assert registry.get('tratar_vas_estrategico').operation_type == 'conversational'
|
||||
assert registry.get('pro_rata').operation_type == 'conversational'
|
||||
assert registry.get('retomar_workflow').operation_type == 'internal'
|
||||
assert registry.defaults.operation_type == 'read_only'
|
||||
58
tests/migration/test_vaa_dispute_parity.py
Normal file
58
tests/migration/test_vaa_dispute_parity.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
|
||||
|
||||
def codes(result):
|
||||
return [x.get("code") for x in result.get("business_events", []) if isinstance(x, dict)]
|
||||
|
||||
|
||||
def test_contestacao_emite_vaa005_006_008_quando_barcode_elegivel():
|
||||
class Client:
|
||||
def billing_analysis(self, msisdn):
|
||||
return {"currentInvoice": [{"desc": "Serviços de valor adicionado", "items": [{"desc": "VAS Musica", "value": 29.9, "classe": "avulso"}]}]}
|
||||
def contestar(self, payload): return {"barcode": "123", "itemsResponse": []}
|
||||
class S: client=Client()
|
||||
action=build_contas_workflow_actions(S()).get("abrir_contestacao_cliente") # type: ignore[arg-type]
|
||||
result=action({"msisdn":"119", "protocolo_id":"P1", "servico":"VAS Musica", "valor":29.9}, {"input":{}})
|
||||
assert result["success"] is True
|
||||
assert codes(result)[:3] == ["VAA.005", "VAA.006", "VAA.008"]
|
||||
|
||||
|
||||
def test_contestacao_emite_vaa005_007_quando_provider_falha():
|
||||
class Client:
|
||||
def billing_analysis(self, msisdn):
|
||||
return {"currentInvoice": [{"desc": "Serviços de valor adicionado", "items": [{"desc": "VAS Musica", "value": 29.9, "classe": "avulso"}]}]}
|
||||
def contestar(self, payload): raise RuntimeError("falha provider")
|
||||
class S: client=Client()
|
||||
action=build_contas_workflow_actions(S()).get("abrir_contestacao_cliente") # type: ignore[arg-type]
|
||||
result=action({"msisdn":"119", "protocolo_id":"P1", "servico":"VAS Musica", "valor":29.9}, {"input":{}})
|
||||
assert result["success"] is False
|
||||
assert codes(result) == ["VAA.005", "VAA.007"]
|
||||
|
||||
|
||||
def test_sms_emite_vaa012_014_e_falha_vaa013_015():
|
||||
class Good:
|
||||
def enviar_sms(self, **kwargs): return {"resource_url":"u"}
|
||||
good=build_contas_workflow_actions(Good()).get("enviar_sms") # type: ignore[arg-type]
|
||||
result=good({"msisdn":"119", "message":"x"}, {"input":{}})
|
||||
assert codes(result)[:2] == ["VAA.012", "VAA.014"]
|
||||
class Bad:
|
||||
def enviar_sms(self, **kwargs): raise RuntimeError("timeout")
|
||||
bad=build_contas_workflow_actions(Bad()).get("enviar_sms") # type: ignore[arg-type]
|
||||
result=bad({"msisdn":"119", "message":"x"}, {"input":{}})
|
||||
assert codes(result) == ["VAA.013", "VAA.015"]
|
||||
assert result["sms_not_send_error"] is True
|
||||
|
||||
|
||||
def test_status_sr_emite_vaa016_ou_017_sem_quebrar_workflow():
|
||||
class GoodClient:
|
||||
def status_sr(self, payload): return {"ok": True}
|
||||
class Good: client=GoodClient()
|
||||
action=build_contas_workflow_actions(Good()).get("atualizar_status_sr") # type: ignore[arg-type]
|
||||
result=action({"msisdn":"119", "protocolo_id":"P1"}, {"input":{}})
|
||||
assert codes(result) == ["VAA.016"] and result["protocol_closed"] is True
|
||||
class BadClient:
|
||||
def status_sr(self, payload): raise RuntimeError("timeout")
|
||||
class Bad: client=BadClient()
|
||||
action=build_contas_workflow_actions(Bad()).get("atualizar_status_sr") # type: ignore[arg-type]
|
||||
result=action({"msisdn":"119", "protocolo_id":"P1"}, {"input":{}})
|
||||
assert codes(result) == ["VAA.017"] and result["protocol_closed"] is False
|
||||
52
tests/migration/test_workflow_execution_latch.py
Normal file
52
tests/migration/test_workflow_execution_latch.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from agent_framework.runtime.agent_runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
class Runtime(AgentRuntimeMixin):
|
||||
pass
|
||||
|
||||
|
||||
def _envelope(status: str, name: str = "invoice_explanation"):
|
||||
return {
|
||||
"result": {
|
||||
"result": {
|
||||
"status": status,
|
||||
"execution_id": "exec-1",
|
||||
"metadata": {
|
||||
"workflow_name": name,
|
||||
"workflow_execution_id": "exec-1",
|
||||
"resume_tool": "retomar_workflow",
|
||||
},
|
||||
"pause": {"message": "Sanei sua dúvida?"} if status == "PAUSED" else None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_workflow_pausado_ja_e_registrado_como_executado():
|
||||
runtime = Runtime()
|
||||
state = {}
|
||||
runtime._capture_pending_domain_workflow(state, _envelope("PAUSED"))
|
||||
assert state["business_workflows_executed"] == ["invoice_explanation"]
|
||||
assert state["pending_domain_workflow"]["workflow_name"] == "invoice_explanation"
|
||||
|
||||
|
||||
def test_workflow_completo_e_registrado_sem_pending():
|
||||
runtime = Runtime()
|
||||
state = {}
|
||||
runtime._capture_pending_domain_workflow(state, _envelope("COMPLETED", "pro_rata"))
|
||||
assert state["business_workflows_executed"] == ["pro_rata"]
|
||||
assert "pending_domain_workflow" not in state
|
||||
|
||||
|
||||
def test_latch_nao_duplica_workflow():
|
||||
runtime = Runtime()
|
||||
state = {}
|
||||
runtime._capture_pending_domain_workflow(state, _envelope("PAUSED"))
|
||||
runtime._capture_pending_domain_workflow(state, _envelope("COMPLETED"))
|
||||
assert state["business_workflows_executed"] == ["invoice_explanation"]
|
||||
|
||||
|
||||
def test_transaction_state_patch_preserva_latch():
|
||||
runtime = Runtime()
|
||||
patch = runtime.transaction_state_patch({"business_workflows_executed": ["invoice_explanation"]})
|
||||
assert patch["business_workflows_executed"] == ["invoice_explanation"]
|
||||
80
tests/migration/test_wrapper_last_contracts.py
Normal file
80
tests/migration/test_wrapper_last_contracts.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from contas_mcp.servers.contas_mcp_server import main as m
|
||||
|
||||
|
||||
def R(status='COMPLETED', output=None, trace=None, error=None, error_details=None):
|
||||
d={'execution_id':'x','status':status,'output':output or {},'trace':trace or [],'state':{},'error':error,'error_details':error_details}
|
||||
return SimpleNamespace(**d, model_dump=lambda: dict(d))
|
||||
|
||||
class Runtime:
|
||||
def __init__(self,*rs): self.rs=list(rs); self.calls=[]
|
||||
async def arun(self,name,payload,execution_id=None):
|
||||
self.calls.append((name,dict(payload),execution_id)); return self.rs.pop(0)
|
||||
|
||||
|
||||
def test_invoice_explanation_preserva_fatura_e_analise_variacao_no_input(monkeypatch):
|
||||
rt=Runtime(R(output={}))
|
||||
monkeypatch.setattr(m,'get_workflow_runtime',lambda:rt)
|
||||
args={'msisdn':'119','invoice_detail':{'119':{'SVA':[]}},'billing_analysis':{'currentInvoice':{'invoiceNumber':'I1'}},'vas_variation':{'added':['X']}}
|
||||
asyncio.run(m._run_workflow('invoice_explanation',args))
|
||||
p=rt.calls[0][1]
|
||||
assert p['invoice_detail']==args['invoice_detail']
|
||||
assert p['billing_analysis']==args['billing_analysis']
|
||||
assert p['vas_variation']==args['vas_variation']
|
||||
|
||||
|
||||
def test_pro_rata_devolucao_forca_composicao_llm_framework():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class S: pass
|
||||
reg=build_contas_workflow_actions(S())
|
||||
a=reg.get('orientar_pagamento_pro_rata')
|
||||
out=a({'refund_option':'proxima_fatura','valor_ajuste':'10,00'}, {'input':{}})
|
||||
assert out.get('requires_llm_composition') is True
|
||||
assert str(out.get('response_instruction') or '').strip()
|
||||
|
||||
|
||||
def test_cancelamento_boleto_zerado_ou_nao_nao_vaza_sms_sent(monkeypatch):
|
||||
# Independentemente do barcode, o contrato externo não expõe o detalhe interno sms_sent.
|
||||
for barcode in ('0'*44,'34191790010104351004791020150008291070026000'):
|
||||
cancel=R(output={'cancelar_vas_avulso':{'success':True,'cancelados':[{'servico':'VAS','valor':'9,90','codigo_boleto':barcode,'msisdn':'119'}], 'contestation_candidates':[], 'protocolos_por_linha':[{'protocolo_id':'P1'}]}})
|
||||
rt=Runtime(cancel); monkeypatch.setattr(m,'get_workflow_runtime',lambda rt=rt:rt)
|
||||
out=asyncio.run(m._run_cancelamento_com_contestacao({'msisdn':'119','subject':'VAS','valor':'9,90'}))
|
||||
assert 'sms_sent' not in out
|
||||
assert 'prompt_signals' not in out
|
||||
|
||||
|
||||
def test_cancelamento_corrige_linha_item_por_invoice_detail():
|
||||
args={'items':[{'msisdn':'111','name':'Filme 2'},{'msisdn':'111','name':'Filme 3'}], 'invoice_detail':{'111':{'SVA':[{'desc':'Filme 2','msisdn':'111'}]},'222':{'SVA':[{'desc':'Filme 3','msisdn':'222'}]}}}
|
||||
got=m._normalize_cancel_items(args,'111')
|
||||
assert [x['msisdn'] for x in got]==['111','222']
|
||||
|
||||
|
||||
def test_cancelamento_compositor_e_deterministico_sem_diretiva_llm():
|
||||
from app.domain.contas.vas_cancellation_message import compose_vas_cancellation_message
|
||||
payload={'cancelados':[{'servico':'TIM Fashion Mensal','valor':'14,99'}], 'contested_items':['TIM Fashion Mensal'], 'contested_invoice_amount_open':'14,99', 'contestacao_protocol':'P100'}
|
||||
text=compose_vas_cancellation_message(payload)
|
||||
assert 'TIM Fashion Mensal' in text and '14,99' in text and 'P100' in text
|
||||
|
||||
|
||||
def test_vas_estrategico_normaliza_nome_e_agrupa_linhas():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class S: pass
|
||||
a=build_contas_workflow_actions(S()).get('preparar_vas_estrategico')
|
||||
p={'items':[{'msisdn':'111','name':'Paramount plus','type':'estrategico'},{'msisdn':'222','name':'YouTube Premium','type':'estrategico'}]}
|
||||
out=a(p,{'input':p})
|
||||
assert len(out['linhas'])==2
|
||||
names=[n for l in out['linhas'] for n in l.get('estrategico_names',[])]
|
||||
assert any('Paramount' in n for n in names)
|
||||
|
||||
|
||||
def test_rag_dominio_apenas_declara_queries_e_framework_executa():
|
||||
from app.domain.contas.workflow_actions import build_contas_workflow_actions
|
||||
class S: pass
|
||||
a=build_contas_workflow_actions(S()).get('buscar_informacao_rag')
|
||||
out=a({'query':'como funciona pro rata','queries':['como funciona pro rata'],'top_k':4,'segment':'POS'}, {'input':{}})
|
||||
assert out['delegate_to_framework_rag'] is True
|
||||
assert out['queries']==['como funciona pro rata']
|
||||
assert out['documents']==[]
|
||||
Reference in New Issue
Block a user