from pathlib import Path import yaml import pytest from contas_mcp.servers.contas_mcp_server import main ROOT = Path(__file__).resolve().parents[2] def test_all_concrete_subject_domains_have_domain_prevalidation(): policies = yaml.safe_load((ROOT / 'config' / 'tool_policies.yaml').read_text(encoding='utf-8'))['tool_policies'] assert policies['contestar_cobranca']['pre_validation']['tool'] == 'validar_contestacao' for tool in ('cancelar_vas_avulso', 'tratar_vas_estrategico'): assert policies[tool]['pre_validation'] == { 'enabled': True, 'tool': 'validar_vas_subject', 'fail_open': False, } def test_vas_subject_schema_requires_concrete_entity_semantically(): tools = yaml.safe_load((ROOT / 'config' / 'tools.yaml').read_text(encoding='utf-8'))['tools'] for tool in ('cancelar_vas_avulso', 'tratar_vas_estrategico'): description = tools[tool]['args_schema']['subject']['description'].lower() assert 'concreto' in description assert 'item real' in description assert 'ausente/null' in description def test_generic_vas_category_does_not_resolve_to_arbitrary_product(): catalog = [ {'name': 'TIM Fashion Mensal'}, {'name': 'Aya Audiobooks Premium'}, {'name': 'Neymar Jr'}, {'name': 'Tamboro Mensal'}, {'name': 'Youtube Premium'}, ] canonical, matches = main._resolve_catalog_entity('streaming', catalog) assert canonical is None assert matches == [] def test_partial_unique_concrete_vas_name_resolves(): catalog = [ {'name': 'TIM Fashion Mensal'}, {'name': 'Aya Audiobooks Premium'}, {'name': 'Neymar Jr'}, ] canonical, matches = main._resolve_catalog_entity('TIM Fashion', catalog) assert canonical == 'TIM Fashion Mensal' assert len(matches) == 1 def test_ambiguous_reference_is_not_silently_resolved(): catalog = [ {'name': 'Apple Music SVA Mes'}, {'name': 'Apple Music Dados Mes'}, ] canonical, matches = main._resolve_catalog_entity('Apple Music', catalog) assert canonical is None assert {item['name'] for item in matches} == {'Apple Music SVA Mes', 'Apple Music Dados Mes'} @pytest.mark.asyncio async def test_vas_validator_returns_needs_parameter_for_generic_category(monkeypatch): async def no_enrich(name, args): return None monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich) monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [ {'name': 'TIM Fashion Mensal'}, {'name': 'Aya Audiobooks Premium'}, {'name': 'Neymar Jr'}, {'name': 'Tamboro Mensal'}, ]) result = await main._validate_vas_subject({ 'msisdn': '11999999999', 'subject': 'streaming', 'target_tool': 'cancelar_vas_avulso', }) assert result['eligible'] is False assert result['status'] == 'NEEDS_PARAMETER' assert result['parameter'] == 'subject' assert result['reason'] == 'subject_not_resolved' @pytest.mark.asyncio async def test_vas_validator_accepts_unique_concrete_entity(monkeypatch): async def no_enrich(name, args): return None monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich) monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [ {'name': 'TIM Fashion Mensal'}, {'name': 'Tamboro Mensal'}, ]) result = await main._validate_vas_subject({ 'msisdn': '11999999999', 'subject': 'TIM Fashion', 'target_tool': 'cancelar_vas_avulso', }) assert result['eligible'] is True assert result['status'] == 'ELIGIBLE' assert result['resolved_subject'] == 'TIM Fashion Mensal' def test_vas_preflight_never_falls_through_when_subject_is_unresolved(): # Existing execution-time preflight is a second defensive layer. It must not # let an unresolved generic entity proceed even if the pre-validation stage # was bypassed by a caller outside the framework runtime. args = { 'msisdn': '11999999999', 'subject': 'streaming', 'billing_analysis': {'currentInvoice': []}, } result = main._preflight_subject('cancelar_vas_avulso', args) assert result is not None assert result['status'] == 'NEEDS_PARAMETER' assert result['parameter'] == 'subject' @pytest.mark.asyncio async def test_vas_validator_redirects_canonical_strategic_entity_before_execution(monkeypatch): from types import SimpleNamespace async def no_enrich(name, args): args['billing_analysis'] = {'currentInvoice': []} return None monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich) monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [ {'name': 'Tamboro Mensal'}, {'name': 'Youtube Premium'}, ]) monkeypatch.setattr( main.invoice_resolver, 'resolve', lambda subjects, billing: SimpleNamespace( resolved=[SimpleNamespace( canonical_name='Youtube Premium', tool_category='vas_estrategico', item_type='streaming', )], ambiguous=[], out_of_scope=[], ), ) result = await main._validate_vas_subject({ 'msisdn': '11999999999', 'subject': 'youtube', 'target_tool': 'cancelar_vas_avulso', }) assert result['eligible'] is True assert result['resolved_subject'] == 'Youtube Premium' decision = result['transaction_decision'] assert decision['resolved_arguments']['subject'] == 'Youtube Premium' assert decision['target_tool'] == 'tratar_vas_estrategico' assert decision['action_changed'] is True assert decision['requires_reconfirmation'] is True assert 'Youtube Premium' in decision['confirmation_message'] def test_vas_domain_policy_uses_invoice_detail_classe_for_strategic_item(): args = { 'invoice_detail': { 'parsed_content': { '1199999999': { 'SVA Detalhe Total': [ { 'desc': 'Youtube Premium', 'value': 10, 'classe': 'estrategico', 'estrategico': True, 'verb': 'falar sobre', }, { 'desc': 'Tamboro Mensal', 'value': 14.99, 'classe': 'avulso', 'verb': 'cancelar', }, ] } } } } assert main._vas_domain_policy_from_invoice_detail('Youtube Premium', args) == ('vas_estrategico', 'estrategico') assert main._vas_domain_policy_from_invoice_detail('Tamboro Mensal', args) == ('cancelar_vas_avulso', 'avulso') @pytest.mark.asyncio async def test_vas_validator_real_invoice_detail_redirects_youtube_without_resolver_guess(monkeypatch): async def enrich(name, args): args['billing_analysis'] = { 'currentInvoice': [ { 'type': 'streaming', 'desc': 'Streamings', 'items': [{'desc': 'Youtube Premium', 'value': '10.0'}], } ] } args['invoice_detail'] = { 'parsed_content': { '1199999999': { 'SVA Detalhe Total': [ { 'desc': 'Youtube Premium', 'value': 10, 'classe': 'estrategico', 'estrategico': True, 'verb': 'falar sobre', } ] } } } monkeypatch.setattr(main, '_enrich_invoice_context', enrich) monkeypatch.setattr(main, '_vas_entity_catalog', lambda args: [{'name': 'Youtube Premium'}]) # If invoice-detail classification works, the generic resolver fallback is unnecessary. monkeypatch.setattr(main.invoice_resolver, 'resolve', lambda *a, **k: (_ for _ in ()).throw(AssertionError('fallback should not run'))) result = await main._validate_vas_subject({ 'msisdn': '11999999999', 'subject': 'youtube', 'target_tool': 'cancelar_vas_avulso', }) decision = result['transaction_decision'] assert result['resolved_subject'] == 'Youtube Premium' assert decision['domain_policy']['class'] == 'vas_estrategico' assert decision['domain_policy']['item_type'] == 'estrategico' assert decision['target_tool'] == 'tratar_vas_estrategico' assert decision['action_changed'] is True assert decision['requires_reconfirmation'] is True def test_multi_item_execution_preflight_does_not_reresolve_joined_subject(): args = { 'msisdn': '11999999999', 'subject': 'Tamboro Mensal, Paramount+', 'items': [ {'name': 'Tamboro Mensal', 'msisdn': '11999999999'}, {'name': 'Paramount+', 'msisdn': '11999999999'}, ], } assert main._preflight_subject('cancelar_vas_avulso', args) is None @pytest.mark.asyncio async def test_contestation_wrong_amount_is_recoverable_with_invoice_amount(monkeypatch): async def no_enrich(name, args): return None monkeypatch.setattr(main, '_enrich_invoice_context', no_enrich) monkeypatch.setattr(main, '_preflight_subject', lambda name, args: None) monkeypatch.setattr(main, 'validate_contestation_items', lambda items, payload: ( items, [{ 'erro': 'valor_ajuste_maior_que_item', 'valor_item_fatura': '14.99', 'item_fatura_resolvido': 'Tamboro Mensal', }], "Valor de ajuste excede o valor cobrado", )) result = await main._validate_contestation({ 'msisdn': '11999999999', 'subject': 'Tamboro Mensal', 'valor': '29.98', 'billing_analysis': {}, 'target_tool': 'contestar_cobranca', }) assert result['eligible'] is False assert result['status'] == 'NEEDS_PARAMETER' assert result['parameter'] == 'valor' assert result['resolved_value'] == '14.99' assert 'R$ 14,99' in result['parameter_message'] assert 'R$ 29,98' in result['parameter_message']