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" @pytest.mark.asyncio async def test_cancelamento_usa_valor_vas_validado_quando_usuario_nao_informa_valor(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-valor", "workflow_name": name, "workflow_version": 1, "status": "COMPLETED", "output": {"cancelar_vas_avulso": { "success": True, "results": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal", "service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}], "contestation_candidates": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal", "service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}], }}, "state": {}, "trace": [], } return { "execution_id": "cont-valor", "workflow_name": name, "workflow_version": 2, "status": "COMPLETED", "output": {"registrar_protocolo": {"protocolo_id": "PRT-V"}, "abrir_contestacao_cliente": {"success": True, "items_response": []}}, "state": {}, "trace": [], } runtime = Runtime() monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime) monkeypatch.setattr(main, "service", FakeService()) await main._run_cancelamento_com_contestacao({"msisdn": "5511999999999", "subject": "Tamboro Mensal"}) item = runtime.calls[1][1]["items"][0] assert item["claimedAmount"] == "14,99" assert item["validatedAmount"] == "14,99" @pytest.mark.asyncio async def test_cancelamento_preserva_valor_informado_e_valor_vas_como_evidencias_distintas(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-div", "workflow_name": name, "workflow_version": 1, "status": "COMPLETED", "output": {"cancelar_vas_avulso": { "success": True, "results": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal", "service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}], "contestation_candidates": [{"success": True, "msisdn": "5511999999999", "subject": "Tamboro Mensal", "service": {"name": "Tamboro Mensal", "details": {"valor": "14,99"}}}], }}, "state": {}, "trace": [], } return { "execution_id": "cont-div", "workflow_name": name, "workflow_version": 2, "status": "COMPLETED", "output": {"registrar_protocolo": {"protocolo_id": "PRT-D"}, "abrir_contestacao_cliente": {"success": True, "items_response": []}}, "state": {}, "trace": [], } runtime = Runtime() monkeypatch.setattr(main, "get_workflow_runtime", lambda: runtime) monkeypatch.setattr(main, "service", FakeService()) await main._run_cancelamento_com_contestacao({ "msisdn": "5511999999999", "subject": "Tamboro Mensal", "valor": "29,98", }) item = runtime.calls[1][1]["items"][0] assert item["claimedAmount"] == "29,98" assert item["validatedAmount"] == "14,99"