Files
agent_contas/tests/migration/test_cancel_composite_workflow.py

603 lines
25 KiB
Python

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"