Files
agent_contas/tests/migration/test_original_vas_variation.py

1437 lines
53 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Testes de ``vas_variation``: quais VAS avulsos variaram entre as duas faturas.
É a base do gatilho e do escopo da retenção de VAS no pedido de humano (SPEC §9),
então um erro aqui cancela o serviço errado do cliente. Cobre as duas formas de
payload (ISO-8601 + ``value`` string, como o runtime entrega; e ``dd/mm/yyyy`` +
float, da serialização alternativa), a partição por vencimento, o desempate de data
única, a exclusão do bucket ``streaming``, a contagem por cobrança (multiset) e todas
as portas de falha fechada."""
from __future__ import annotations
import logging
from decimal import Decimal
from pathlib import Path
import pytest
import yaml
from app.domain.contas.invoice_resolver import InvoiceResolver
from app.domain.contas.vas_variation import (
VariedCharge,
charge_match_key,
varied_avulso_items,
varied_current_charges,
varied_vas_charges,
variation_source,
)
PAST = "2026-03-10T00:00:00.000Z"
CURRENT = "2026-04-07T00:00:00.000Z"
_REPO_ROOT = Path(__file__).resolve().parents[2]
# ----- factories ------------------------------------------------------------
def _item(desc: str, value, invoice: str, **extra) -> dict:
return {"desc": desc, "value": value, "invoice": invoice, **extra}
def _group(*items: dict, type_: str = "servicos_contratados_de_parceiros") -> dict:
return {
"type": type_,
"desc": "Serviços de valor adicionado",
"nItems": str(len(items)),
"items": list(items),
}
def _payload(*groups: dict, current: list | None = None) -> dict:
payload: dict = {"invoiceVariation": list(groups)}
if current is not None:
payload["currentInvoice"] = current
return payload
def _current_group(*descs_values) -> list:
return [
{
"type": "servicos_contratados_de_parceiros",
"desc": "Serviços de valor adicionado",
"items": [
{"desc": d, "value": v, "contestable": True} for d, v in descs_values
],
}
]
def _names(charges: tuple[VariedCharge, ...]) -> list[str]:
return sorted(c.desc for c in charges)
# ----- caminho principal ----------------------------------------------------
def test_entrou_na_atual_e_detectado():
"""Serviço presente só na fatura atual → variou (entrou)."""
payload = _payload(
_group(
_item("Aluguel de Filme 1", "19.9", PAST),
_item("Aluguel de Filme 1", "19.9", CURRENT),
_item("Aluguel de Filme 3", "19.9", CURRENT),
)
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Aluguel de Filme 3", value=Decimal("19.90")),
)
def test_inalterados_nao_entram():
"""Itens idênticos nas duas faturas não variaram — e são a MAIORIA do grupo
(o payload lista as duas faturas inteiras, não só o diff)."""
payload = _payload(
_group(
_item("Carnal Mensal", "16.99", PAST),
_item("MasterChef Mensal", "19.99", PAST),
_item("Carnal Mensal", "16.99", CURRENT),
_item("MasterChef Mensal", "19.99", CURRENT),
)
)
assert varied_current_charges(payload) == ()
def test_mudanca_de_valor_conta_o_valor_novo():
"""Mesmo serviço com valor diferente: entra a cobrança da fatura ATUAL."""
payload = _payload(
_group(
_item("Games Station Mensal", "14.9", PAST),
_item("Games Station Mensal", "16.99", CURRENT),
)
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Games Station Mensal", value=Decimal("16.99")),
)
def test_removido_nao_oferta():
"""Variação causada só por REMOÇÃO: o avulso saiu e a conta caiu. Não há o que
cancelar → nada a ofertar (decisão de produto, SPEC §9)."""
payload = _payload(
_group(
_item("Monicaverso Semanal", "5.99", PAST),
_item("Carnal Mensal", "16.99", PAST),
_item("Carnal Mensal", "16.99", CURRENT),
)
)
assert varied_current_charges(payload) == ()
def test_cobranca_duplicada_conta_as_duas():
"""O mesmo serviço cobrado 2× no ciclo são DUAS cobranças reais: a segunda
entra como variação (multiset, não conjunto)."""
payload = _payload(
_group(
_item("Qualifica Mensal", "19.99", PAST),
_item("Qualifica Mensal", "19.99", CURRENT),
_item("Qualifica Mensal", "19.99", CURRENT),
)
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Qualifica Mensal", value=Decimal("19.99")),
)
def test_duas_cobrancas_identicas_novas_contam_as_duas():
"""Duas cobranças IDÊNTICAS (mesmo nome E mesmo valor) que entraram na fatura
atual devolvem DUAS ``VariedCharge`` — um ``set`` colapsaria em uma e metade da
variação ficaria sem cancelar."""
payload = _payload(
_group(
_item("Aluguel de Filme 1", "19.9", CURRENT),
_item("Aluguel de Filme 1", "19.9", CURRENT),
),
current=_current_group(
("Aluguel de Filme 1", "19.9"), ("Aluguel de Filme 1", "19.9")
),
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Aluguel de Filme 1", value=Decimal("19.90")),
VariedCharge(desc="Aluguel de Filme 1", value=Decimal("19.90")),
)
def test_duas_identicas_com_uma_na_passada_conta_apenas_uma():
"""Três cobranças iguais no total (1 na passada, 2 na atual): só UMA entrou. O
multiset cancela o par e sobra uma — cancelar as duas cobraria do cliente um
cancelamento que ele não pediu."""
payload = _payload(
_group(
_item("Aluguel de Filme 1", "19.9", PAST),
_item("Aluguel de Filme 1", "19.9", CURRENT),
_item("Aluguel de Filme 1", "19.9", CURRENT),
)
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Aluguel de Filme 1", value=Decimal("19.90")),
)
# ----- classes que NÃO disparam ---------------------------------------------
def test_streaming_e_ignorado():
"""O bucket ``streaming`` é o VAS ESTRATÉGICO e nunca dispara retenção."""
payload = _payload(
_group(
_item("Netflix Padrão", "20.9", CURRENT),
_item("Disney+ Premium", "62.9", CURRENT),
type_="streaming",
)
)
assert varied_current_charges(payload) == ()
def test_grupo_avulso_ausente():
"""Só grupos de outras categorias (plano, juros) → nada de avulso."""
payload = _payload(
_group(_item("TIM Black Premium 2 0", "239.99", CURRENT), type_="plano")
)
assert varied_current_charges(payload) == ()
# ----- desempate de data única ---------------------------------------------
def test_data_unica_casando_a_atual_conta_como_entrada():
"""Uma só data no grupo (a outra fatura não tinha avulso): se as cobranças
aparecem em ``currentInvoice``, o bloco é o lado ATUAL → tudo entrou."""
payload = _payload(
_group(
_item("Gamedom Mensal", "14.99", CURRENT),
_item("Carnal Mensal", "16.99", CURRENT),
),
current=_current_group(("Gamedom Mensal", "14.99"), ("Carnal Mensal", "16.99")),
)
assert _names(varied_current_charges(payload)) == [
"Carnal Mensal",
"Gamedom Mensal",
]
def test_data_unica_sem_casar_a_atual_conta_como_saida():
"""Uma só data e as cobranças NÃO estão na fatura atual: o bloco é o lado
PASSADO (tudo saiu) → nada a cancelar."""
payload = _payload(
_group(
_item("Gamedom Mensal", "14.99", PAST),
_item("Carnal Mensal", "16.99", PAST),
),
current=_current_group(("Netflix Padrão", "20.9")),
)
assert varied_current_charges(payload) == ()
def test_data_unica_sem_current_invoice_nao_oferta():
"""Sem ``currentInvoice`` não há como desempatar a direção → falha fechada."""
payload = _payload(_group(_item("Gamedom Mensal", "14.99", CURRENT)))
assert varied_current_charges(payload) == ()
def test_data_unica_casa_item_em_outro_bucket_da_fatura_atual():
"""Os dois campos vêm de geradores distintos e DISCORDAM da classe do mesmo
item (caso real: avulso na variação, ``streaming`` na fatura atual). O
desempate pergunta "está na fatura atual?", não "de que classe é" — a classe é
do resolver. Restringir ao bucket avulso inverteria a direção e perderia a
cobrança."""
payload = _payload(
_group(_item("Truecaller Family Mensal", "13.99", CURRENT)),
current=[
{
"type": "streaming",
"desc": "Streamings",
"items": [{"desc": "Truecaller Family Mensal", "value": "13.99"}],
}
],
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Truecaller Family Mensal", value=Decimal("13.99")),
)
# ----- formas de payload ----------------------------------------------------
def test_forma_alternativa_ddmmyyyy_e_float():
"""Serialização alternativa do mesmo pipeline: ``dd/mm/yyyy`` e ``value``
float. Ordena por data real, não lexicograficamente."""
payload = _payload(
_group(
_item("Fluid Mensal", 14.99, "10/07/2026"),
_item("Fluid Mensal", 14.99, "10/08/2026"),
_item("Neymar Jr.Experience-", 14.99, "10/08/2026"),
)
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Neymar Jr.Experience-", value=Decimal("14.99")),
)
def test_ordenacao_por_data_nao_lexicografica():
"""``dd/mm/yyyy``: 09/12/2025 é ANTERIOR a 09/01/2026 — ordenar como string
inverteria a direção e cancelaria o item errado."""
payload = _payload(
_group(
_item("Apps Club Mensal", 12.99, "09/12/2025"),
_item("Apps Club Mensal", 12.99, "09/01/2026"),
_item("Pocoyo Mensal", 19.99, "09/01/2026"),
)
)
assert varied_current_charges(payload) == (
VariedCharge(desc="Pocoyo Mensal", value=Decimal("19.99")),
)
def test_bucket_por_msisdn():
"""``invoiceVariation`` dentro de um bucket por linha (fatura família) é lido
igual ao do top-level."""
payload = {
"Fatura Resumo": {"total": "100"},
"2196460-1930": {
"invoiceVariation": [
_group(
_item("Fit Me App", "16.99", PAST),
_item("Fit Me App", "16.99", CURRENT),
_item("Budz Premium mensal", "14.99", CURRENT),
)
]
},
}
assert varied_current_charges(payload) == (
VariedCharge(desc="Budz Premium mensal", value=Decimal("14.99")),
)
def test_desc_devolvido_e_o_cru_da_fatura():
"""O ``desc`` devolvido é o texto CRU (com acento/pontuação), nunca o
normalizado usado para casar."""
payload = _payload(
_group(_item("VOD +Canais Abertos", "19.9", CURRENT)),
current=_current_group(("VOD +Canais Abertos", "19.9")),
)
assert varied_current_charges(payload)[0].desc == "VOD +Canais Abertos"
# ----- falha fechada --------------------------------------------------------
@pytest.mark.parametrize(
"payload",
[
pytest.param(None, id="none"),
pytest.param({}, id="vazio"),
pytest.param({"invoiceVariation": []}, id="lista-vazia"),
pytest.param({"invoiceVariation": "nao-e-lista"}, id="tipo-errado"),
pytest.param({"invoiceVariation": [None, 42]}, id="grupos-invalidos"),
pytest.param(
{"invoiceVariation": [{"type": "servicos_contratados_de_parceiros"}]},
id="grupo-sem-items",
),
],
)
def test_payload_invalido_devolve_vazio(payload):
"""Sem dado utilizável não oferta retenção — nunca promete cancelar o que não
se sabe ter variado."""
assert varied_current_charges(payload) == ()
def test_vencimento_ilegivel_aborta():
"""Item que não pode ser atribuído a nenhuma das duas faturas corromperia o
diff — e um diff errado cancela o serviço errado. Aborta tudo."""
payload = _payload(
_group(
_item("Carnal Mensal", "16.99", PAST),
_item("Gamedom Mensal", "14.99", "sem-data"),
_item("Gamedom Mensal", "14.99", CURRENT),
)
)
assert varied_current_charges(payload) == ()
def test_item_sem_valor_nao_entra():
"""Cobrança sem identidade (valor não parseável) fica fora do diff."""
payload = _payload(
_group(
_item("Carnal Mensal", "16.99", PAST),
_item("Carnal Mensal", "16.99", CURRENT),
_item("Servico Sem Valor", "n/a", CURRENT),
)
)
assert varied_current_charges(payload) == ()
# ----- charge_match_key -----------------------------------------------------
def test_charge_match_key_normaliza_nome_e_valor():
"""A chave é a MESMA dos dois lados do casamento (cobrança variada × item do
resolver): acento/caixa/``+`` normalizados, valor com 2 casas."""
assert charge_match_key("VOD + Canais Abertos", "19.9") == charge_match_key(
"vod +canais abertos", Decimal("19.90")
)
@pytest.mark.parametrize(
"desc,value",
[("", "19.9"), (None, "19.9"), ("Carnal Mensal", None), ("Carnal Mensal", "n/a")],
)
def test_charge_match_key_sem_identidade(desc, value):
assert charge_match_key(desc, value) is None
# ----- varied_avulso_items: cruzamento com a classificação da fatura ---------
#
# ``varied_current_charges`` (acima) sabe o que VARIOU; só o PDF parseado sabe o que é
# CANCELÁVEL. ``varied_avulso_items`` cruza os dois, e é a função que os DOIS
# consumidores chamam — a retenção no pedido de humano (SPEC §9) e o ramo NÃO do
# ``invoice_explanation``. Os dois guards contra estratégico mis-bucketado pela API
# vivem aqui, e antes só eram exercitados de through-the-runtime.
def _detail(sections: dict) -> dict:
"""``invoice_detail`` de uma linha, no formato que o resolver espera."""
return {"11999999999": sections}
def _entrou(*descs_values) -> dict:
"""Análise em que TODAS as cobranças entraram 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 avulso)."""
return _payload(
_group(*(_item(d, v, CURRENT) for d, v in descs_values)),
current=_current_group(*descs_values),
)
def _varied(invoice_detail: dict, variation: dict | None = None, *, by_charge=False):
return varied_avulso_items(
invoice_variation=variation,
invoice_detail=invoice_detail,
resolver=InvoiceResolver(),
by_charge=by_charge,
)
def test_avulso_variado_casa_nome_e_valor():
"""O caminho feliz: o serviço variou na análise E está na fatura como avulso."""
detail = _detail(
{"SVA Detalhe Total": [{"desc": "TIM Fashion Mensal", "value": 10.0}]}
)
items = _varied(detail, _entrou(("TIM Fashion Mensal", "10.0")))
assert [it.canonical_name for it in items] == ["TIM Fashion Mensal"]
def test_guard_nome_estrategico_no_bucket_avulso_da_api():
"""Guard 1: a API bucketa streaming como avulso (visto em fatura real com
``YouTube Premium Mensal``) e a seção classifica por ``contestable``, então o item
sairia ``avulso``. A lista fixa §12.A vence — estratégico não é cancelável."""
detail = _detail(
{"SVA Detalhe Total": [{"desc": "YouTube Premium Mensal", "value": 24.9}]}
)
assert _varied(detail, _entrou(("YouTube Premium Mensal", "24.9"))) == []
def test_guard_classe_mista_na_mesma_fatura():
"""Guard 2: o MESMO nome aparece como avulso numa seção e como bundle noutra —
discordância entre os dois geradores da API. A não-cancelável vence."""
detail = _detail(
{
"SVA Detalhe Total": [{"desc": "Tamboro Mensal", "value": 14.99}],
"Serviços Bundle Inclusos": [{"desc": "Tamboro Mensal", "value": 14.99}],
}
)
assert _varied(detail, _entrou(("Tamboro Mensal", "14.99"))) == []
def test_valor_divergente_entre_as_fontes_nao_casa(caplog):
"""Casar só por nome pegaria a cobrança ERRADA do mesmo serviço (2 cobranças, só
uma variou). Fora da lista — e logado, para a taxa não sumir."""
detail = _detail(
{"SVA Detalhe Total": [{"desc": "Tamboro Mensal", "value": 14.99}]}
)
with caplog.at_level(logging.INFO):
assert _varied(detail, _entrou(("Tamboro Mensal", "19.99"))) == []
assert "value_mismatch" in caplog.text
def test_variado_ausente_da_fatura_e_logado(caplog):
"""A análise diz que variou, mas o serviço não está na fatura atual."""
detail = _detail(
{"SVA Detalhe Total": [{"desc": "Tamboro Mensal", "value": 14.99}]}
)
with caplog.at_level(logging.INFO):
assert _varied(detail, _entrou(("Serviço Fantasma", "5.0"))) == []
assert "name_absent" in caplog.text
def test_sem_variacao_devolve_lista_vazia():
"""Distinto de ``None``: sabemos que nada variou."""
detail = _detail(
{"SVA Detalhe Total": [{"desc": "Tamboro Mensal", "value": 14.99}]}
)
assert _varied(detail, _payload(_group())) == []
def test_sem_fatura_devolve_none():
"""``None`` = não deu para decidir. Cada consumidor escolhe o que fazer com a
dúvida (a retenção não oferta; o ramo NÃO devolve ao orquestrador)."""
assert _varied({}, _entrou(("X", "1.0"))) is None
def test_variation_source_prefere_a_analise():
payload = _payload(_group(_item("X", "1.0", CURRENT)))
assert variation_source(payload, {"outro": 1}) is payload
def test_variation_source_cai_no_invoice_detail():
"""Entradas em que o próprio ``invoice_detail`` já é o payload de análise."""
detail = _payload(_group(_item("X", "1.0", CURRENT)))
assert variation_source({}, detail) is detail
assert variation_source(None, detail) is detail
# ----- varied_vas_charges: recorte AVULSO + ESTRATÉGICO ----------------------
#
# O ramo NÃO do ``invoice_explanation`` não pergunta "o que eu cancelo?" (aí só avulso
# serve, e é o recorte da retenção acima) — pergunta "existe assunto que eu trate?". O
# estratégico o agente trata pela tool ``vas_estrategico``, então ele conta.
#
# E, ao contrário da retenção, esta função NÃO cruza com o PDF: o ``type`` do grupo já é
# a classe. Os testes abaixo provam as duas metades disso — o grupo ``streaming`` entra,
# e a fatura não é consultada em nenhum caminho.
def _entrou_vas(*, avulsos=(), estrategicos=()) -> dict:
"""Análise em que TODAS as cobranças entraram na fatura atual, nos dois grupos de
VAS (avulso e ``streaming``) — a forma que o runtime entrega quando a fatura passada
não tinha VAS."""
groups = []
if avulsos:
groups.append(_group(*(_item(d, v, CURRENT) for d, v in avulsos)))
if estrategicos:
groups.append(
_group(
*(_item(d, v, CURRENT) for d, v in estrategicos), type_="streaming"
)
)
return _payload(*groups, current=_current_group(*avulsos, *estrategicos))
def _varied_vas(variation: dict | None, invoice_detail: dict | None = None):
return varied_vas_charges(
invoice_variation=variation, invoice_detail=invoice_detail
)
def test_vas_variado_pega_o_estrategico_que_variou():
"""Variação causada só por streaming: o recorte da retenção não vê nada (não é
cancelável), o do ramo NÃO vê — e é a diferença que faz o atendimento voltar ao
orquestrador em vez de finalizar ``nao_resolvido``."""
variation = _entrou_vas(estrategicos=[("Max Mensal", "34.9")])
detail = _detail({"Streamings": [{"desc": "Max Mensal", "value": 34.9}]})
assert _names(_varied_vas(variation)) == ["Max Mensal"]
assert _varied(detail, variation) == []
def test_vas_variado_pega_o_avulso_tambem():
"""O recorte é mais largo, não outro: o avulso continua contando."""
charges = _varied_vas(_entrou_vas(avulsos=[("TIM Fashion Mensal", "10.0")]))
assert _names(charges) == ["TIM Fashion Mensal"]
def test_vas_variado_soma_os_dois_grupos():
variation = _entrou_vas(
avulsos=[("Tamboro Mensal", "14.99")], estrategicos=[("Max Mensal", "34.9")]
)
assert _names(_varied_vas(variation)) == ["Max Mensal", "Tamboro Mensal"]
def test_vas_variado_particiona_pela_uniao_dos_grupos():
"""A partição passada × atual é feita sobre a UNIÃO dos dois grupos. Aqui só o
avulso tem as duas faturas e o streaming existe só na atual: lido sozinho, o
streaming cairia no desempate de data única (que exige ``currentInvoice``); na
união, os dois vencimentos do avulso dão a direção."""
variation = _payload(
_group(
_item("Tamboro Mensal", "19.99", PAST),
_item("Tamboro Mensal", "19.99", CURRENT),
),
_group(_item("Max Mensal", "34.9", CURRENT), type_="streaming"),
)
assert _names(_varied_vas(variation)) == ["Max Mensal"]
def test_vas_variado_nao_consulta_a_fatura():
"""O contrato que separa este recorte do da retenção: a decisão sai só da análise.
O mesmo payload de variação devolve o MESMO resultado com fatura ausente, com
fatura que classifica o item como ``bundle`` e com fatura ilegível — três entradas
que, no cruzamento antigo, davam três respostas diferentes e ENCERRAVAM a ligação."""
variation = _entrou_vas(avulsos=[("Tamboro Mensal", "14.99")])
faturas = [
None,
{},
_detail({"Serviços Bundle Inclusos": [{"desc": "Tamboro Mensal", "value": 14.99}]}),
{"vocalized_msisdn": {}}, # PDF não processado (incidente Neymar Jr.Experience)
_detail({"SVA Detalhe Total": [{"desc": "Tamboro Mensal Premium", "value": 17.00}]}),
]
assert all(_names(_varied_vas(variation, f)) == ["Tamboro Mensal"] for f in faturas)
def test_vas_variado_sem_variacao_devolve_vazio():
"""Nada variou nos dois grupos → finaliza ``nao_resolvido``."""
assert _varied_vas(_payload(_group())) == ()
def test_vas_variado_sem_analise_devolve_vazio():
"""Análise ausente é indistinguível de "nada variou" — falha fechada de
``varied_current_charges``, herdada por este recorte (decisão de 2026-08-10)."""
assert _varied_vas({"invoiceVariation": []}) == ()
assert _varied_vas(None) == ()
def test_vas_variado_ignora_grupo_que_nao_e_vas():
"""Plano, juros e roaming vivem em outros ``type`` e não entram no recorte."""
variation = _payload(
_group(_item("Torpedos Internacionais", "0.79", CURRENT), type_="pacote_do_tim_viagem_roaming"),
_group(_item("Multas", "2.59", CURRENT), type_="juros_e_multa_por_atraso_no_pagamento"),
current=_current_group(("Torpedos Internacionais", "0.79"), ("Multas", "2.59")),
)
assert _varied_vas(variation) == ()
def test_vas_variado_le_a_analise_do_invoice_detail_como_fallback():
"""``variation_source``: entradas em que o ``invoice_detail`` JÁ é o payload de
análise. É o único uso que sobrou do parâmetro."""
analise = _entrou_vas(avulsos=[("Tamboro Mensal", "14.99")])
assert _names(_varied_vas(None, analise)) == ["Tamboro Mensal"]
def test_grupo_streaming_fora_do_recorte_avulso():
"""A retenção lê SÓ o grupo avulso: streaming na variação não vira cobrança
variada (é o default de ``varied_current_charges``)."""
payload = _payload(
_group(_item("Max Mensal", "34.9", CURRENT), type_="streaming"),
current=_current_group(("Max Mensal", "34.9")),
)
assert varied_current_charges(payload) == ()
# ----- regressão contra dados reais -----------------------------------------
#
# A regra foi validada durante o desenvolvimento contra 88 pares de fatura reais
# (88/88, usando as faturas normalizadas como verdade). Esse corpus NÃO virou fixture:
# medido por mutação, ele pegava 6 de 8 defeitos injetados e nenhum que os testes acima
# já não peguem — era cego justamente ao multiset e ao parse de data. Somado a não ser
# regenerável (as CSVs de origem vivem fora do repo), virava asserção opaca. Os dois
# testes abaixo cobrem o que importa em dado real: a FORMA do payload de runtime.
#
# O dado vem COM o teste, não de mock do runtime: mock de gateway é editado à mão em
# teste manual, e um teste que o lê quebra por edição legítima. O cenário cy0001 é a
# exceção consciente — ele é a captura de um atendimento real e tem guarda de skip.
# Payload copiado do mock do gateway fake (`billingAnalysis` de `divergencia.json`),
# mas MANTIDO AQUI de propósito: aquele arquivo é mock de desenvolvimento e é editado à
# mão para exercitar ramos em teste manual (esvaziar `invoiceVariation` reproduz o ramo
# "sem VAS avulso"). Lendo-o, este teste ficava vermelho por causa de uma edição
# legítima — falha pelo motivo errado. O dado é do teste; o mock é do runtime local.
_FATURA_COM_AVULSOS_VARIADOS = {
"invoiceVariation": [
{
"value": "46.99",
"type": "servicos_contratados_de_parceiros",
"desc": "Serviços de valor adicionado",
"nItems": "10",
"items": [
# Fatura PASSADA (venc. 20/10) — 3 cobranças.
_item("Tamboro Mensal", "19.99", "2025-10-20T00:00:00.000Z"),
_item("VOD + Canais abertos", "10.0", "2025-10-20T00:00:00.000Z"),
_item("VOD + Canais abertos", "10.0", "2025-10-20T00:00:00.000Z"),
# Fatura ATUAL (venc. 20/11) — as 3 acima, inalteradas...
_item("Tamboro Mensal", "19.99", "2025-11-20T00:00:00.000Z"),
_item("VOD + Canais abertos", "10.0", "2025-11-20T00:00:00.000Z"),
_item("VOD + Canais abertos", "10.0", "2025-11-20T00:00:00.000Z"),
# ...mais 4 que ENTRARAM.
_item("Tamboro Mensal", "14.99", "2025-11-20T00:00:00.000Z"),
_item("TIM Fashion Mensal", "10.0", "2025-11-20T00:00:00.000Z"),
_item("Neymar Jr", "12.0", "2025-11-20T00:00:00.000Z"),
_item("Youtube Premium", "10.0", "2025-11-20T00:00:00.000Z"),
],
}
]
}
def test_fatura_realista_expoe_so_os_avulsos_que_entraram():
"""Payload no formato do runtime (ISO-8601 + ``value`` string), com as três
situações que convivem numa fatura real:
- **entraram:** ``Tamboro Mensal`` R$14,99, ``TIM Fashion Mensal`` R$10,00,
``Neymar Jr`` R$12,00 e ``Youtube Premium`` R$10,00;
- **não variaram:** ``Tamboro Mensal`` R$19,99 — mesmo NOME de um que variou, outra
cobrança: é a discriminação por VALOR que a feature existe para fazer — e
``VOD + Canais abertos`` R$10,00 ×2 (multiplicidade preservada dos dois lados);
- ``Youtube Premium`` vem no bucket avulso, reproduzindo o mis-bucket real da API;
barrá-lo é do ``varied_avulso_items`` (guard de estratégico), não daqui.
Soma da variação: R$46,99 = R$36,99 canceláveis + R$10,00 do estratégico."""
got = {
(c.desc, str(c.value))
for c in varied_current_charges(_FATURA_COM_AVULSOS_VARIADOS)
}
assert got == {
("Tamboro Mensal", "14.99"),
("TIM Fashion Mensal", "10.00"),
("Neymar Jr", "12.00"),
("Youtube Premium", "10.00"),
}
canceláveis = {v for d, v in got if d != "Youtube Premium"}
assert sum(Decimal(v) for v in canceláveis) == Decimal("36.99")
def test_cenario_real_cy0001_pega_so_o_item_que_variou():
"""Payload de runtime REAL (ISO-8601 + ``value`` string, `nItems` camelCase).
A fatura tem 5 avulsos cobrados, 4 deles idênticos ao ciclo anterior — só
`Aluguel de Filme 3` entrou. O critério antigo ("a fatura TEM avulso")
enfileirava os 5 para cancelamento."""
scenario = (
_REPO_ROOT
/ "automated_conversation_test/scenarios"
/ "cy0001_explicacao_de_fatura_planos_pos"
/ "cy0001_explicacao_de_fatura_planos_pos.yaml"
)
if not scenario.exists(): # pragma: no cover - cenário opcional no checkout
pytest.skip("cenário cy0001 ausente")
payloads: list[dict] = []
def walk(node):
if isinstance(node, dict):
if "invoiceVariation" in node:
payloads.append(node)
for value in node.values():
walk(value)
elif isinstance(node, list):
for value in node:
walk(value)
walk(yaml.safe_load(scenario.read_text(encoding="utf-8")))
assert payloads, "cenário sem invoiceVariation"
assert varied_current_charges(payloads[0]) == (
VariedCharge(desc="Aluguel de Filme 3", value=Decimal("19.90")),
)
# ----- nome curto na variação × nome qualificado na fatura -------------------
# Incidente e081812886a (URA 06/08): a análise de variação traz o nome CURTO do
# serviço ("FIT ME App") e a fatura traz o nome COMPLETO ("FIT ME App Premium
# Mensal"). A igualdade estrita de nome descartava o item, ``_has_retention_vas``
# devolvia False e o pedido de atendente encerrava ``nao_resolvido`` sem oferecer
# a retenção. O casamento é por PREFIXO DE TOKENS, com trava de valor ao centavo,
# item avulso pós-guards e candidato único — ambiguidade falha FECHADA (SPEC §9).
@pytest.fixture
def prefixo_de_tokens_ligado(monkeypatch):
"""Liga ``TIM_VAS_QUALIFIED_NAME_MATCH``.
A segunda passada é DESLIGADA por padrão (ver o comentário da flag em
``vas_variation``): o mesmo prefixo qualifica o mesmo produto em
"FIT ME App""FIT ME App Premium Mensal" e separa produtos DIFERENTES em
"Fluid Light"×"Fluid Premium". Os testes abaixo fixam o comportamento da
passada em si, então precisam ligá-la explicitamente."""
from app.domain.contas import vas_variation
monkeypatch.setattr(vas_variation, "_VAS_QUALIFIED_NAME_MATCH", True)
def test_prefixo_de_tokens_desligado_por_padrao():
"""Contrato do default: sem a flag, o caso do incidente NÃO casa.
É o custo aceito de eliminar o falso positivo "TIM Games""TIM Games
Premium", que tem forma idêntica e nenhum sinal disponível para separar."""
detail = _detail(
{"Itens Eventuais": [{"desc": "FIT ME App Premium Mensal", "value": 16.99}]}
)
assert _varied(detail, _entrou(("FIT ME App", "16.99"))) == []
def test_nome_curto_casa_com_nome_qualificado_da_fatura(prefixo_de_tokens_ligado):
"""Com a flag ligada: variação "FIT ME App" × fatura "FIT ME App Premium
Mensal", mesmo valor → casa."""
detail = _detail(
{"Itens Eventuais": [{"desc": "FIT ME App Premium Mensal", "value": 16.99}]}
)
items = _varied(detail, _entrou(("FIT ME App", "16.99")))
assert [it.canonical_name for it in items] == ["FIT ME App Premium Mensal"]
def test_nome_identico_continua_casando_pela_primeira_passada():
"""A segunda passada não pode roubar o que a igualdade estrita já resolveu:
nome idêntico casa uma única vez (sem duplicar o item)."""
detail = _detail(
{"Itens Eventuais": [{"desc": "FIT ME App", "value": 16.99}]}
)
items = _varied(detail, _entrou(("FIT ME App", "16.99")))
assert [it.canonical_name for it in items] == ["FIT ME App"]
def test_valor_divergente_nao_casa_mesmo_com_prefixo(prefixo_de_tokens_ligado):
"""Trava de valor: prefixo de nome bate, centavo não → fora. Cancelar por
nome parecido com valor diferente é cancelar o serviço errado."""
detail = _detail(
{"Itens Eventuais": [{"desc": "FIT ME App Premium Mensal", "value": 29.90}]}
)
assert _varied(detail, _entrou(("FIT ME App", "16.99"))) == []
def test_prefixo_no_meio_do_nome_nao_casa(prefixo_de_tokens_ligado):
"""Não é substring solta: os tokens da variação têm de ser o INÍCIO do nome
do item. "ME App" no meio de "FIT ME App Premium" não casa."""
detail = _detail(
{"Itens Eventuais": [{"desc": "FIT ME App Premium Mensal", "value": 16.99}]}
)
assert _varied(detail, _entrou(("ME App", "16.99"))) == []
def test_ambiguidade_falha_fechada(caplog, prefixo_de_tokens_ligado):
"""Dois itens com o mesmo prefixo e o mesmo valor → NENHUM é escolhido.
SPEC §9: nunca prometer cancelamento do que não se sabe ter variado."""
detail = _detail(
{
"Itens Eventuais": [
{"desc": "Gamedom Mensal", "value": 16.99},
{"desc": "Gamedom Semanal", "value": 16.99},
]
}
)
with caplog.at_level(logging.INFO):
items = _varied(detail, _entrou(("Gamedom", "16.99")))
assert items == []
assert "reason=multiple_candidates" in caplog.text
def test_nome_da_variacao_mais_longo_que_o_da_fatura_nao_casa(prefixo_de_tokens_ligado):
"""Direção única: a variação é o prefixo do item, nunca o contrário. Nome da
variação mais específico que o da fatura não casa (evita casar "X Premium"
com um "X" que pode ser outro serviço)."""
detail = _detail({"Itens Eventuais": [{"desc": "FIT ME App", "value": 16.99}]})
assert _varied(detail, _entrou(("FIT ME App Premium Mensal", "16.99"))) == []
def test_estrategico_com_nome_qualificado_continua_fora(prefixo_de_tokens_ligado):
"""Os guards não são contornáveis pela segunda passada: estratégico não é
cancelável (SPEC §12.A), mesmo casando por prefixo e valor."""
detail = _detail(
{"Itens Eventuais": [{"desc": "YouTube Premium Mensal", "value": 24.90}]}
)
assert _varied(detail, _entrou(("YouTube Premium", "24.90"))) == []
# ----- linha de destino: msisdn e ambiguidade entre linhas -------------------
# A fila desta rota é autorizada SEM confirmação pendente, e o ``msisdn`` vai cru
# para os args de ``cancelar_vas_avulso``. Cancelar na linha errada — ou sem
# linha — é dano irreversível no cliente, então os dois casos falham FECHADOS.
# Nenhum destes cenários tinha cobertura antes: os helpers do arquivo montam uma
# única linha (``_detail``), e a suíte inteira era cega a fatura multi-linha.
def _detail_multi(buckets: dict) -> dict:
"""``invoice_detail`` com VÁRIAS linhas: ``{msisdn: {seção: [entries]}}``."""
return dict(buckets)
def test_msisdn_ausente_nao_produz_item_cancelavel(caplog):
"""Bucket sem linha → nenhum item cancelável, com razão própria.
Sem ``msisdn`` não há para onde mandar o cancelamento. O descarte não pode
sair como ``name_absent``: o nome estava lá, faltava a linha."""
detail = _detail_multi({"": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]}})
with caplog.at_level(logging.INFO):
items = _varied(detail, _entrou(("Fit Me App", "16.99")))
assert items == []
assert "reason=missing_msisdn" in caplog.text
def test_mesma_cobranca_em_duas_linhas_falha_fechada(caplog):
"""Mesmo ``(nome, valor)`` em duas linhas e só UMA variou → não se sabe qual."""
detail = _detail_multi(
{
"11999999999": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
"11888888888": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
}
)
with caplog.at_level(logging.INFO):
items = _varied(detail, _entrou(("Fit Me App", "16.99")))
assert items == []
assert "reason=multiple_candidates" in caplog.text
def test_ordem_das_linhas_nao_altera_a_decisao():
"""A ordem do payload nunca pode mudar o resultado.
Antes, o loop era item-driven e consumia a cobrança no PRIMEIRO item casado —
o cancelamento saía na linha que o dict listasse antes."""
linha_a = {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]}
linha_b = {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]}
ab = _varied(_detail_multi({"11999999999": linha_a, "11888888888": linha_b}),
_entrou(("Fit Me App", "16.99")))
ba = _varied(_detail_multi({"11888888888": linha_b, "11999999999": linha_a}),
_entrou(("Fit Me App", "16.99")))
assert ab == ba == []
def test_cobranca_so_na_linha_secundaria_casa_naquela_linha():
"""Fatura multi-linha NÃO é motivo de recusa: o que barra é a chave ambígua.
Com o serviço em uma só das linhas há candidato único → casa, e o ``msisdn``
devolvido é o da linha CERTA (não o da primeira do payload)."""
detail = _detail_multi(
{
"11999999999": {"Itens Eventuais": [{"desc": "Fluid Light", "value": 12.90}]},
"11888888888": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
}
)
items = _varied(detail, _entrou(("Fit Me App", "16.99")))
assert [(it.canonical_name, it.msisdn) for it in items] == [
("Fit Me App", "11888888888")
]
def test_multiplicidade_preservada_quando_a_contagem_bate():
"""SPEC §9: o mesmo serviço cobrado 2× no ciclo são DUAS cobranças, e cada uma
entra na fila. A trava de candidato é por contagem, não "no máximo um" — duas
variações + duas cobranças na mesma linha casam as duas."""
detail = _detail(
{
"Itens Eventuais": [
{"desc": "Aluguel de Filme 1", "value": 19.90, "period": "05/04/26"},
{"desc": "Aluguel de Filme 1", "value": 19.90, "period": "18/04/26"},
]
}
)
items = _varied(
detail,
_entrou(("Aluguel de Filme 1", "19.90"), ("Aluguel de Filme 1", "19.90")),
by_charge=True,
)
assert [it.canonical_name for it in items] == [
"Aluguel de Filme 1",
"Aluguel de Filme 1",
]
# ----- ORDEM DOS GUARDS: identidade antes de executabilidade -----------------
# O caso que separa um matcher correto de um perigoso. Com uma cobrança variada
# e DUAS identidades possíveis — uma executável, outra não —, filtrar por
# ``msisdn`` primeiro COROARIA a executável: o descarte da outra viraria
# "evidência" de que sobrou a certa. Não é. A ambiguidade de identidade é
# anterior e independente da executabilidade.
def _duas_cobrancas_iguais(desc: str, value: str) -> dict:
"""Análise em que a MESMA cobrança variou duas vezes no ciclo."""
return _payload(
_group(_item(desc, value, CURRENT), _item(desc, value, CURRENT)),
current=_current_group((desc, value), (desc, value)),
)
def test_uma_variacao_e_duas_identidades_uma_sem_msisdn_falha_fechada(caplog):
"""CASO A: 1 cobrança variada, 2 itens de mesma identidade (um sem linha).
O item sem ``msisdn`` NÃO pode fazer o outro vencer por eliminação."""
detail = {
"11999999999": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
"": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
}
with caplog.at_level(logging.INFO):
items = _varied(detail, _entrou(("Fit Me App", "16.99")))
assert items == []
assert "reason=multiple_candidates" in caplog.text
def test_ordem_nao_altera_o_caso_a():
"""CASO A permutado: quem aparece primeiro no payload é irrelevante."""
com_linha = {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]}
sem_linha = {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]}
variacao = _entrou(("Fit Me App", "16.99"))
ab = _varied({"11999999999": com_linha, "": sem_linha}, variacao)
ba = _varied({"": sem_linha, "11999999999": com_linha}, variacao)
assert ab == ba == []
def test_duas_variacoes_e_duas_identidades_uma_sem_msisdn(caplog):
"""CASO B: 2 cobranças variadas para 2 identidades → não há ambiguidade.
A executável entra na fila; a sem linha é descartada com a razão própria e
NÃO herda o ``msisdn`` da outra."""
detail = {
"11999999999": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
"": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
}
with caplog.at_level(logging.INFO):
items = _varied(detail, _duas_cobrancas_iguais("Fit Me App", "16.99"))
assert [(it.canonical_name, it.msisdn) for it in items] == [
("Fit Me App", "11999999999")
]
assert "reason=missing_msisdn" in caplog.text
# a razão certa, e SÓ ela
assert "reason=name_absent" not in caplog.text
assert "reason=value_mismatch" not in caplog.text
def test_descarte_emite_exatamente_um_evento_por_cobranca(caplog):
"""Telemetria: uma cobrança descartada gera UM evento.
Reportar a mesma cobrança como ``multiple_candidates`` e depois como
``value_mismatch`` no resíduo corrompe a taxa de não-casamento."""
detail = {
"11999999999": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
"11888888888": {"Itens Eventuais": [{"desc": "Fit Me App", "value": 16.99}]},
}
with caplog.at_level(logging.INFO):
_varied(detail, _entrou(("Fit Me App", "16.99")))
eventos = [m for m in caplog.messages if "varied_charge_unmatched" in m]
assert len(eventos) == 1, eventos
assert "reason=multiple_candidates" in eventos[0]
# ----- desempate por DATA DE COBRANÇA ----------------------------------------
# A análise de variação não carrega data utilizável (o campo estruturado vem
# sentinela/ausente em produção) e não carrega msisdn. A única fonte de data é o
# texto CRU do ``invoiceExplanation`` (``invoice_explanation_base``):
#
# * Cobrança FIT ME App no valor de R$ 16.99 no dia 29/06/26.
#
# O PDF traz a outra ponta em ``ResolvedInvoiceItem.charge_date`` (do ``period``).
# A data só ESTREITA candidatos que hoje seriam descartados — nunca amplia, nunca
# elimina por ausência, e a cardinalidade tem de fechar exatamente.
from app.domain.contas.vas_variation import ( # noqa: E402
_charge_date_key,
_explained_charges,
)
def _det_linhas(*entries) -> dict:
"""``invoice_detail`` multi-linha: ``(desc, value, period, msisdn)``."""
buckets: dict = {}
for desc, value, period, msisdn in entries:
entry = {"desc": desc, "value": value}
if period is not None:
entry["period"] = period
buckets.setdefault(msisdn, {}).setdefault("SVA Detalhe Total", []).append(entry)
return buckets
def _varied_expl(detail, variation, explanation=None, *, by_charge=True):
return varied_avulso_items(
invoice_variation=variation,
invoice_detail=detail,
resolver=InvoiceResolver(),
by_charge=by_charge,
explanation_text=explanation,
)
def _bullet(desc, value, dia):
return f"* Cobrança {desc} no valor de R$ {value} no dia {dia}."
# ----- normalizador ----------------------------------------------------------
@pytest.mark.parametrize(
"raw,esperado",
[
("2025-11-01T00:00:00.000Z", ("2025", "11", "01")),
("01/11/25", ("2025", "11", "01")),
("25/06/26", ("2026", "06", "25")),
("01/11/2025", ("2025", "11", "01")),
("0000-12-31T20:53:32.000Z", None), # sentinela do backend
("", None),
(None, None),
("lixo", None),
("32/13/25", None), # data impossível
],
ids=lambda v: str(v)[:26],
)
def test_charge_date_key_normaliza_ou_recusa(raw, esperado):
"""Sentinela e ilegível viram ``None``: "não sei" tem de ser indistinguível
de "não tem", senão vira desempate falso."""
assert _charge_date_key(raw) == esperado
def test_explained_charges_so_extrai_bullet_com_data():
"""Formatos sem dia não participam — sem data não há evidência."""
texto = (
"Houve a variação dos seguintes serviços de valor adicionado:\n"
"* Cobrança FIT ME App no valor de R$ 16.99 no dia 29/06/26.\n"
"* Gamedom Mensal no valor de R$ 14,99 no dia 25/06/26.\n"
"* MasterChef variou em R$ 19.99.\n"
"Foram cobrados os seguintes serviços de terceiros: PARAGUAI."
)
achados = _explained_charges(texto)
assert [(c.desc, str(c.value), c.date_key) for c in achados] == [
("FIT ME App", "16.99", ("2026", "06", "29")),
("Gamedom Mensal", "14.99", ("2026", "06", "25")),
]
@pytest.mark.parametrize("texto", ["", " ", None, "texto sem nada", "x" * 20000])
def test_explained_charges_degrada_sem_quebrar(texto):
assert _explained_charges(texto) == [] or all(
c.date_key for c in _explained_charges(texto)
)
# ----- Caso A: nome qualificado (o incidente e081812886a) --------------------
def test_nome_qualificado_casa_por_valor_e_data():
""""FIT ME App" na análise × "FIT ME App Premium Mensal" no PDF: os nomes
divergem, mas valor ao centavo + data identificam a MESMA cobrança."""
detail = _det_linhas(
("FIT ME App Premium Mensal", "16.99", "29/06/26", "11999999999")
)
variacao = _entrou(("FIT ME App", "16.99"))
assert _varied_expl(detail, variacao) == [] # sem explicação: fail closed
items = _varied_expl(detail, variacao, _bullet("FIT ME App", "16.99", "29/06/26"))
assert [(it.canonical_name, it.msisdn) for it in items] == [
("FIT ME App Premium Mensal", "11999999999")
]
def test_nome_qualificado_com_data_divergente_nao_casa():
"""A ponte é (valor, data). Data diferente → não é a mesma cobrança."""
detail = _det_linhas(
("FIT ME App Premium Mensal", "16.99", "29/06/26", "11999999999")
)
assert (
_varied_expl(
detail,
_entrou(("FIT ME App", "16.99")),
_bullet("FIT ME App", "16.99", "30/06/26"),
)
== []
)
# ----- Caso B: mesmo valor, produtos diferentes, datas diferentes ------------
def test_mesmo_valor_produtos_diferentes_datas_distintas():
"""Dois produtos ao mesmo preço não podem ser separados por valor — a data
separa, e os DOIS casam corretamente."""
detail = _det_linhas(
("MasterChef Mensal", "19.99", "14/06/26", "11999999999"),
("Bancah Jornais II", "19.99", "18/06/26", "11999999999"),
)
variacao = _entrou(("MasterChef Mensal", "19.99"), ("Bancah Jornais II", "19.99"))
explicacao = (
_bullet("MasterChef Mensal", "19.99", "14/06/26")
+ "\n"
+ _bullet("Bancah Jornais II", "19.99", "18/06/26")
)
items = _varied_expl(detail, variacao, explicacao)
assert sorted(it.canonical_name for it in items) == [
"Bancah Jornais II",
"MasterChef Mensal",
]
# ----- Caso C: titular × dependente (a colisão de linha) ---------------------
def test_titular_e_dependente_datas_diferentes_casa_a_linha_certa(caplog):
"""Mesmo nome, mesmo valor, DUAS linhas: a data diz qual variou."""
detail = _det_linhas(
("TIM Fashion Mensal", "10.00", "01/11/25", "1199999999"),
("TIM Fashion Mensal", "10.00", "30/04/25", "91998188119"),
)
with caplog.at_level(logging.INFO):
items = _varied_expl(
detail,
_entrou(("TIM Fashion Mensal", "10.00")),
_bullet("TIM Fashion Mensal", "10.00", "01/11/25"),
)
assert [(it.canonical_name, it.msisdn) for it in items] == [
("TIM Fashion Mensal", "1199999999")
]
assert "reason=charge_date" in caplog.text
def test_titular_e_dependente_mesma_data_falha_fechada(caplog):
"""Datas iguais não desempatam nada."""
detail = _det_linhas(
("TIM Fashion Mensal", "10.00", "01/11/25", "1199999999"),
("TIM Fashion Mensal", "10.00", "01/11/25", "91998188119"),
)
with caplog.at_level(logging.INFO):
items = _varied_expl(
detail,
_entrou(("TIM Fashion Mensal", "10.00")),
_bullet("TIM Fashion Mensal", "10.00", "01/11/25"),
)
assert items == []
assert "reason=multiple_candidates" in caplog.text
assert "date_hint=ambiguous" in caplog.text
def test_candidato_sem_data_nao_pode_ser_eliminado(caplog):
"""A REGRA CRÍTICA: ausência de data NÃO é evidência negativa.
Com um candidato datado e outro sem data, escolher o datado seria decidir
por eliminação — proibido."""
detail = _det_linhas(
("TIM Fashion Mensal", "10.00", "01/11/25", "1199999999"),
("TIM Fashion Mensal", "10.00", None, "91998188119"),
)
with caplog.at_level(logging.INFO):
items = _varied_expl(
detail,
_entrou(("TIM Fashion Mensal", "10.00")),
_bullet("TIM Fashion Mensal", "10.00", "01/11/25"),
)
assert items == []
assert "date_hint=undated" in caplog.text
def test_ambos_sem_data_falha_fechada():
detail = _det_linhas(
("TIM Fashion Mensal", "10.00", None, "1199999999"),
("TIM Fashion Mensal", "10.00", None, "91998188119"),
)
assert (
_varied_expl(
detail,
_entrou(("TIM Fashion Mensal", "10.00")),
_bullet("TIM Fashion Mensal", "10.00", "01/11/25"),
)
== []
)
def test_sem_hint_para_a_cobranca_falha_fechada(caplog):
"""Explicação existe mas não menciona ESTA cobrança."""
detail = _det_linhas(
("TIM Fashion Mensal", "10.00", "01/11/25", "1199999999"),
("TIM Fashion Mensal", "10.00", "30/04/25", "91998188119"),
)
with caplog.at_level(logging.INFO):
items = _varied_expl(
detail,
_entrou(("TIM Fashion Mensal", "10.00")),
_bullet("Outro Servico", "99.99", "01/11/25"),
)
assert items == []
assert "date_hint=absent" in caplog.text
def test_sentinela_na_explicacao_nao_vira_evidencia():
"""Bullet com data sentinela é descartado no parser."""
detail = _det_linhas(
("TIM Fashion Mensal", "10.00", "01/11/25", "1199999999"),
("TIM Fashion Mensal", "10.00", "30/04/25", "91998188119"),
)
assert (
_varied_expl(
detail,
_entrou(("TIM Fashion Mensal", "10.00")),
"* Cobrança TIM Fashion Mensal no valor de R$ 10.00 no dia 31/12/00.",
)
== []
)
# ----- cardinalidade e reuso -------------------------------------------------
def test_resolucao_parcial_e_proibida():
"""``missing=2`` e a data identifica só 1 → nada casa (tudo ou nada)."""
detail = _det_linhas(
("Aya Books Premium", "9.99", "10/06/26", "11999999999"),
("Aya Books Premium", "9.99", "20/06/26", "11999999999"),
("Aya Books Premium", "9.99", "30/06/26", "11999999999"),
)
variacao = _entrou(("Aya Books Premium", "9.99"), ("Aya Books Premium", "9.99"))
items = _varied_expl(
detail, variacao, _bullet("Aya Books Premium", "9.99", "10/06/26")
)
assert items == []
def test_multiplicidade_real_dois_hints_casam_dois_itens():
"""Cardinalidade fecha: 2 cobranças variadas, 2 hints, 2 itens datados."""
detail = _det_linhas(
("Aya Books Premium", "9.99", "10/06/26", "11999999999"),
("Aya Books Premium", "9.99", "20/06/26", "11999999999"),
)
variacao = _entrou(("Aya Books Premium", "9.99"), ("Aya Books Premium", "9.99"))
explicacao = (
_bullet("Aya Books Premium", "9.99", "10/06/26")
+ "\n"
+ _bullet("Aya Books Premium", "9.99", "20/06/26")
)
items = _varied_expl(detail, variacao, explicacao)
assert len(items) == 2
assert sorted(it.charge_date for it in items) == ["10/06/26", "20/06/26"]
def test_hint_nao_e_reutilizado_por_duas_cobrancas():
"""Um bullet identifica UMA cobrança.
Duas chaves distintas (nomes curtos diferentes) disputam o mesmo hint no
passe residual: a primeira o consome, a segunda fica sem evidência e falha
fechada — o hint NÃO é reaproveitado."""
detail = _det_linhas(
("Aya Books Premium", "9.99", "10/06/26", "11999999999"),
("Aya Ensinah Premium", "9.99", "20/06/26", "11999999999"),
)
variacao = _entrou(("Aya Books", "9.99"), ("Aya Ensinah", "9.99"))
items = _varied_expl(
detail, variacao, _bullet("Aya Books", "9.99", "10/06/26")
)
assert [it.canonical_name for it in items] == ["Aya Books Premium"]
def test_guards_valem_no_passe_por_data():
"""Estratégico não é resgatado pela data."""
detail = _det_linhas(
("YouTube Premium Mensal", "24.90", "10/06/26", "11999999999")
)
assert (
_varied_expl(
detail,
_entrou(("YouTube Premium", "24.90")),
_bullet("YouTube Premium", "24.90", "10/06/26"),
)
== []
)
def test_msisdn_ausente_continua_bloqueando_no_passe_por_data(caplog):
"""Guard 3 vale nos dois passes: identidade resolvida, mas sem linha."""
detail = _det_linhas(("FIT ME App Premium Mensal", "16.99", "29/06/26", ""))
with caplog.at_level(logging.INFO):
items = _varied_expl(
detail,
_entrou(("FIT ME App", "16.99")),
_bullet("FIT ME App", "16.99", "29/06/26"),
)
assert items == []
assert "reason=missing_msisdn" in caplog.text
def test_explicacao_nao_altera_casamento_ja_valido():
"""Nenhum match existente pode regredir por a explicação existir."""
detail = _det_linhas(("Tamboro Mensal", "19.99", "02/11/25", "11999999999"))
variacao = _entrou(("Tamboro Mensal", "19.99"))
sem = _varied_expl(detail, variacao)
com = _varied_expl(detail, variacao, _bullet("Tamboro Mensal", "19.99", "02/11/25"))
assert [it.canonical_name for it in sem] == ["Tamboro Mensal"]
assert [it.canonical_name for it in com] == ["Tamboro Mensal"]
def test_passe_residual_exige_cardinalidade_exata():
"""Duas cobranças datadas candidatas para UMA variada → nada casa.
Sem a igualdade estrita (``!= missing``), um ``< missing`` deixaria a fila
levar as DUAS — cancelando uma cobrança que ninguém disse ter variado."""
detail = _det_linhas(
("Aya Books Premium", "9.99", "10/06/26", "11999999999"),
("Aya Ensinah Premium", "9.99", "10/06/26", "11999999999"),
)
explicacao = (
_bullet("Aya", "9.99", "10/06/26") + "\n" + _bullet("Aya", "9.99", "10/06/26")
)
assert _varied_expl(detail, _entrou(("Aya", "9.99")), explicacao) == []