Implantação da geração de mensagem de forma livre e mudança na engine de mensagem

This commit is contained in:
2026-08-24 17:32:16 -03:00
parent 94bbe7556f
commit 88f19dd8f5
77 changed files with 4650 additions and 25 deletions

View File

@@ -53,6 +53,34 @@ FactoryBot.define do
valor_aplicado { 15.0 }
end
factory :grupo_contato do
sequence(:nome) { |n| "Grupo #{n}" }
ativo { true }
end
factory :contato do
sequence(:nome) { |n| "Contato #{n}" }
sequence(:telefone) { |n| format('11 9%04d-%04d', n % 10000, (n * 7) % 10000) }
ativo { true }
association :grupo_contato
end
factory :evento_notificacao do
sequence(:nome) { |n| "Evento #{n}" }
sequence(:chave) { |n| "evento_#{n}" }
gatilho { 'manual' }
ativo { true }
notificar_envolvido { true }
hora { 8 }
end
factory :grupo_evento_assinatura do
association :grupo_contato
association :evento_notificacao
canal { 'ambos' }
ativo { true }
end
factory :historico_estimado do
data_hora { Time.current }
valor_total_estimado { 150.0 }

View File

@@ -0,0 +1,59 @@
require 'rails_helper'
RSpec.describe DetectarMudancasOperacaoJob do
let(:tabela) { 'gade_entregas_teste' }
before do
create(:evento_notificacao, gatilho: 'operacao_alterada', chave: 'operacao_alterada')
allow(Operacao).to receive(:nomes_validos).and_return([tabela])
allow(Operacao).to receive(:label).with(tabela).and_return('TESTE')
allow(Notificacao::Gatilhos).to receive(:operacao_alterada)
end
def com_numeros(total:, entregues:, nao_entregues:, pendentes:)
allow(Analytics::OperacaoMetricas).to receive(:new).and_return(
instance_double(Analytics::OperacaoMetricas, total: total, sucesso: entregues,
recusas: nao_entregues, pendentes: pendentes)
)
end
# Sem retrato anterior toda operação pareceria "nova", e o primeiro deploy
# dispararia uma mensagem por operação cadastrada.
it 'primeira execução só grava o retrato, sem avisar' do
com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2)
expect { described_class.perform_now }.to change(OperacaoSnapshot, :count).by(1)
expect(Notificacao::Gatilhos).not_to have_received(:operacao_alterada)
end
it 'não avisa quando nada mudou' do
com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2)
described_class.perform_now
described_class.perform_now
expect(Notificacao::Gatilhos).not_to have_received(:operacao_alterada)
end
it 'descreve o que mudou entre os dois retratos' do
com_numeros(total: 100, entregues: 90, nao_entregues: 8, pendentes: 2)
described_class.perform_now
com_numeros(total: 103, entregues: 92, nao_entregues: 8, pendentes: 3)
described_class.perform_now
expect(Notificacao::Gatilhos).to have_received(:operacao_alterada) do |args|
expect(args[:operacao]).to eq('TESTE')
expect(args[:o_que_mudou]).to include('3 notas na operação a mais')
expect(args[:o_que_mudou]).to include('2 entregues a mais')
end
expect(OperacaoSnapshot.find_by(tabela: tabela).total_notas).to eq(103)
end
it 'não varre nada quando nenhum evento usa esse gatilho' do
EventoNotificacao.where(gatilho: 'operacao_alterada').destroy_all
expect(Analytics::OperacaoMetricas).not_to receive(:new)
described_class.perform_now
end
end

View File

@@ -0,0 +1,72 @@
require 'rails_helper'
RSpec.describe DispararEventosAgendadosJob do
let!(:evento) do
create(:evento_notificacao, gatilho: 'agendado', frequencia: 'diaria', hora: 8, ativo: true)
end
let(:agora) { Time.zone.local(2026, 8, 24, 9, 0) }
let(:resumo) do
instance_double(Analytics::ResumoOperacao, vazio?: false,
numeros: { 'entregues' => '10', 'recusas' => '1', 'pendentes' => '0',
'retentativas' => '0', 'fora_operacao' => '0' })
end
before do
allow(Analytics::ResumoOperacao).to receive(:new).and_return(resumo)
allow(Notificacao::Despachante).to receive(:disparar)
end
it 'dispara o evento vencido e marca o disparo' do
described_class.perform_now(agora)
expect(Notificacao::Despachante).to have_received(:disparar)
expect(evento.reload.ultimo_disparo_em).to be_present
end
# `vencido?` depende de ultimo_disparo_em; sem gravar, a varredura horária
# mandaria a mesma mensagem a cada hora até o dia virar.
it 'não repete no mesmo dia' do
described_class.perform_now(agora)
described_class.perform_now(agora + 1.hour)
expect(Notificacao::Despachante).to have_received(:disparar).once
end
it 'não dispara antes da hora configurada' do
described_class.perform_now(Time.zone.local(2026, 8, 24, 7, 0))
expect(Notificacao::Despachante).not_to have_received(:disparar)
end
it 'ignora evento desativado' do
evento.update!(ativo: false)
described_class.perform_now(agora)
expect(Notificacao::Despachante).not_to have_received(:disparar)
end
# Resumo zerado só treinaria o leitor a ignorar a mensagem — mas precisa
# marcar como disparado, senão tenta de hora em hora.
it 'período sem movimento: não envia, mas marca o disparo' do
allow(resumo).to receive(:vazio?).and_return(true)
described_class.perform_now(agora)
expect(Notificacao::Despachante).not_to have_received(:disparar)
expect(evento.reload.ultimo_disparo_em).to be_present
end
describe 'janela do resumo (recortes FECHADOS, para o número não mudar depois de enviado)' do
it 'diária cobre o dia anterior' do
described_class.perform_now(agora)
expect(Analytics::ResumoOperacao).to have_received(:new)
.with(inicio: Date.new(2026, 8, 23), fim: Date.new(2026, 8, 23))
end
it 'semanal cobre os 7 dias anteriores' do
evento.update!(frequencia: 'semanal', dia_semana: agora.to_date.wday)
described_class.perform_now(agora)
expect(Analytics::ResumoOperacao).to have_received(:new)
.with(inicio: Date.new(2026, 8, 17), fim: Date.new(2026, 8, 23))
end
end
end

View File

@@ -0,0 +1,43 @@
require 'rails_helper'
RSpec.describe Contato do
it 'normaliza o telefone digitado para E.164 na gravação' do
# O ADM digita com máscara; mandar esse texto cru para o provedor devolve
# erro de destino inválido que o rescue do envio esconderia.
%w[(11)92005-1157 11920051157 011\ 92005\ 1157 +5511920051157].each do |digitado|
contato = build(:contato, telefone: digitado)
expect(contato).to be_valid
expect(contato.telefone).to eq('+5511920051157')
end
end
it 'exige ao menos um canal' do
contato = build(:contato, telefone: nil, email: nil)
expect(contato).not_to be_valid
expect(contato.errors.full_messages.join).to include('WhatsApp ou um e-mail')
end
it 'aceita contato só de e-mail' do
expect(build(:contato, telefone: nil, email: 'diretoria@reem.com')).to be_valid
end
it 'rejeita telefone que não dá para normalizar, devolvendo o valor digitado' do
contato = build(:contato, telefone: '123')
expect(contato).not_to be_valid
expect(contato.errors[:telefone].join).to include('número brasileiro')
# O ADM precisa ver o que digitou para corrigir.
expect(contato.telefone).to eq('123')
end
describe '.com_canal' do
let!(:so_zap) { create(:contato, telefone: '11 92005-1157', email: nil) }
let!(:so_email) { create(:contato, telefone: nil, email: 'a@b.com') }
let!(:ambos) { create(:contato, telefone: '11 93005-1157', email: 'c@d.com') }
it 'filtra por quem tem o canal' do
expect(Contato.com_canal('whatsapp')).to contain_exactly(so_zap, ambos)
expect(Contato.com_canal('email')).to contain_exactly(so_email, ambos)
expect(Contato.com_canal('ambos')).to contain_exactly(so_zap, so_email, ambos)
end
end
end

View File

@@ -0,0 +1,69 @@
require 'rails_helper'
RSpec.describe EventoNotificacao do
it 'gera a chave a partir do nome quando não informada' do
evento = described_class.create!(nome: 'Aviso da Diretoria', gatilho: 'manual')
expect(evento.chave).to eq('aviso_da_diretoria')
end
it 'exige dia da semana na frequência semanal' do
evento = build(:evento_notificacao, gatilho: 'agendado', frequencia: 'semanal', dia_semana: nil)
expect(evento).not_to be_valid
expect(evento.errors[:dia_semana]).to be_present
end
it 'evento de sistema não é apagável' do
expect(build(:evento_notificacao, sistema: true).apagavel?).to be(false)
expect(build(:evento_notificacao, sistema: false).apagavel?).to be(true)
end
describe '#destinatarios' do
let(:evento) { create(:evento_notificacao) }
let(:diretoria) { create(:grupo_contato) }
let(:operacao) { create(:grupo_contato) }
it 'junta os contatos de todos os grupos assinantes, sem repetir' do
a = create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157')
b = create(:contato, grupo_contato: operacao, telefone: '11 93005-1157')
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento)
create(:grupo_evento_assinatura, grupo_contato: operacao, evento_notificacao: evento)
expect(evento.reload.destinatarios('whatsapp')).to contain_exactly(a, b)
end
it 'respeita o canal da assinatura' do
so_email = create(:contato, grupo_contato: diretoria, telefone: nil, email: 'dir@reem.com')
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'email')
expect(evento.reload.destinatarios('email')).to contain_exactly(so_email)
expect(evento.reload.destinatarios('whatsapp')).to be_empty
end
it 'ignora assinatura desativada e contato desativado' do
create(:contato, grupo_contato: diretoria, ativo: false)
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, ativo: false)
expect(evento.reload.destinatarios('whatsapp')).to be_empty
end
end
describe '#vencido?' do
let(:evento) do
create(:evento_notificacao, gatilho: 'agendado', frequencia: 'diaria', hora: 8)
end
it 'vence depois da hora e só uma vez por dia' do
manha = Time.zone.local(2026, 8, 24, 9, 0)
expect(evento.vencido?(manha)).to be(true)
evento.update!(ultimo_disparo_em: Time.zone.local(2026, 8, 24, 8, 5))
expect(evento.vencido?(manha)).to be(false)
expect(evento.vencido?(Time.zone.local(2026, 8, 25, 9, 0))).to be(true)
end
it 'não vence antes da hora' do
expect(evento.vencido?(Time.zone.local(2026, 8, 24, 7, 0))).to be(false)
end
end
end

View File

@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe MensagemTemplate do
let(:evento) { create(:evento_notificacao, gatilho: 'pagamento_efetuado') }
def montar(blocos, **extra)
described_class.new({ evento_notificacao: evento, canal: 'whatsapp', blocos: blocos }.merge(extra))
end
# `blocos` vem de um hidden preenchido por JavaScript — ou seja, de fora.
describe 'limpeza dos blocos na gravação' do
it 'descarta tipo fora do catálogo' do
t = montar([{ 'tipo' => 'iframe', 'texto' => 'x' }, { 'tipo' => 'texto', 'texto' => 'ok' }])
t.validate
expect(t.blocos.map { |b| b['tipo'] }).to eq(['texto'])
end
it 'descarta campo que o tipo não tem' do
t = montar([{ 'tipo' => 'divisor', 'onload' => 'alert(1)' }])
t.validate
expect(t.blocos.first.keys).to eq(['tipo'])
end
it 'descarta linha de tabela sem rótulo nem valor' do
t = montar([{ 'tipo' => 'tabela', 'linhas' => [{ 'rotulo' => '', 'valor' => '' },
{ 'rotulo' => 'Valor', 'valor' => '10' }] }])
t.validate
expect(t.blocos.first['linhas'].size).to eq(1)
end
it 'limita a quantidade de blocos' do
t = montar(Array.new(60) { { 'tipo' => 'divisor' } })
t.validate
expect(t.blocos.size).to eq(described_class::MAX_BLOCOS)
end
it 'aceita blocos com chave símbolo (vindos de código, não do form)' do
t = montar([{ tipo: 'texto', texto: 'oi' }])
t.validate
expect(t.blocos).to eq([{ 'tipo' => 'texto', 'texto' => 'oi' }])
end
end
it 'exige assunto no e-mail com conteúdo' do
t = montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], canal: 'email', assunto: '')
expect(t).not_to be_valid
expect(t.errors[:assunto]).to be_present
end
# Template ativo e vazio mandaria mensagem em branco — pior que o texto padrão.
it 'template vazio não é utilizável mesmo estando ativo' do
expect(montar([], ativo: true).utilizavel?).to be(false)
expect(montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], ativo: true).utilizavel?).to be(true)
expect(montar([{ 'tipo' => 'texto', 'texto' => 'oi' }], ativo: false).utilizavel?).to be(false)
end
it 'não deixa dois templates do mesmo canal no mesmo evento' do
described_class.create!(evento_notificacao: evento, canal: 'whatsapp', blocos: [])
duplicado = described_class.new(evento_notificacao: evento, canal: 'whatsapp', blocos: [])
expect(duplicado).not_to be_valid
end
it 'preview usa os valores de amostra do gatilho' do
t = montar([{ 'tipo' => 'texto', 'texto' => 'Valor: {{valor}}' }])
expect(t.preview).to include(Notificacao::Variaveis.amostra('pagamento_efetuado')['valor'])
end
end

View File

@@ -0,0 +1,134 @@
require 'rails_helper'
RSpec.describe Notificacao::Despachante do
let(:evento) { create(:evento_notificacao) }
let(:diretoria) { create(:grupo_contato) }
let(:config) { ConfiguracaoNotificacao.instancia }
before do
config.update_columns(whatsapp_ativo: true, whatsapp_provedor: 'baileys',
email_notificacoes_ativo: true, whatsapp_intervalo_segundos: 0)
# Nunca tocar a ponte de verdade no teste.
allow(Notificacao::Whatsapp).to receive(:enviar)
.and_return(Notificacao::ClienteWhatsapp::Resposta.new(ok: true))
allow(ConfiguracaoNotificacao).to receive(:instancia).and_return(config)
end
def despachar(envolvido: nil, dados: {})
described_class.new(evento.reload, dados: dados, assunto: 'Assunto',
corpo: 'Corpo', envolvido: envolvido).executar
end
it 'envia para os contatos dos grupos assinantes e registra cada envio' do
contato = create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com')
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento)
expect { despachar }.to change(NotificacaoEnvio, :count).by(2) # whatsapp + email
expect(Notificacao::Whatsapp).to have_received(:enviar)
.with(hash_including(para: contato.telefone))
expect(NotificacaoEnvio.where(canal: 'whatsapp').first).to be_enviado
end
# A mesma pessoa pode ser contato de um grupo E a envolvida no fato — sem
# dedup ela receberia a mensagem duas vezes.
it 'não manda duas vezes para o mesmo número' do
create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: nil)
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'whatsapp')
motorista = create(:motorista, telefone: '(11) 92005-1157')
despachar(envolvido: motorista)
expect(NotificacaoEnvio.where(canal: 'whatsapp').count).to eq(1)
end
it 'inclui o envolvido que não é contato de nenhum grupo' do
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento)
motorista = create(:motorista, telefone: '(11) 98888-7777')
despachar(envolvido: motorista)
expect(NotificacaoEnvio.where(canal: 'whatsapp').pluck(:destino)).to eq(['+5511988887777'])
end
it 'não avisa o envolvido quando o evento diz para não avisar' do
evento.update!(notificar_envolvido: false)
motorista = create(:motorista, telefone: '(11) 98888-7777')
despachar(envolvido: motorista)
expect(NotificacaoEnvio.where(canal: 'whatsapp')).to be_empty
end
it 'registra a falha em vez de levantar — envio não pode travar um fechamento' do
create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: nil)
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento, canal: 'whatsapp')
allow(Notificacao::Whatsapp).to receive(:enviar)
.and_return(Notificacao::ClienteWhatsapp::Resposta.new(ok: false, erro: 'whatsapp desconectado'))
expect { despachar }.not_to raise_error
envio = NotificacaoEnvio.last
expect(envio).to be_falhou
expect(envio.erro).to include('desconectado')
end
# O editor de blocos vence o texto padrão — e o fallback existe para que ligar
# o editor não apague as notificações dos eventos que já funcionavam.
describe 'template de blocos' do
before do
create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com')
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento)
end
it 'sem template, usa o corpo passado pelo chamador' do
despachar
expect(NotificacaoEnvio.find_by(canal: 'whatsapp').corpo).to eq('Corpo')
end
it 'com template, o corpo sai renderizado por canal' do
MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true,
blocos: [{ 'tipo' => 'texto', 'texto' => 'Oi {{contato}}, {{valor}}' }])
MensagemTemplate.create!(evento_notificacao: evento, canal: 'email', ativo: true,
assunto: 'Fechamento de {{contato}}',
blocos: [{ 'tipo' => 'texto', 'texto' => 'Corpo do e-mail' }])
despachar(dados: { 'valor' => 'R$ 10,00' })
zap = NotificacaoEnvio.find_by(canal: 'whatsapp')
expect(zap.corpo).to include('R$ 10,00')
email = NotificacaoEnvio.find_by(canal: 'email')
expect(email.corpo).to include('<p') # HTML montado
expect(email.assunto).to include('Fechamento')
end
# {{contato}} é o primeiro nome de QUEM RECEBE: um render compartilhado
# mandaria o nome da primeira pessoa para todo mundo.
it 'renderiza {{contato}} por destinatário' do
create(:contato, grupo_contato: diretoria, nome: 'Ana Paula', telefone: '11 93005-1157', email: nil)
MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true,
blocos: [{ 'tipo' => 'texto', 'texto' => 'Olá {{contato}}' }])
despachar
corpos = NotificacaoEnvio.where(canal: 'whatsapp').pluck(:corpo)
expect(corpos).to include(a_string_including('Ana'))
expect(corpos.uniq.size).to eq(corpos.size)
end
it 'template ativo mas vazio não substitui o texto padrão' do
MensagemTemplate.create!(evento_notificacao: evento, canal: 'whatsapp', ativo: true, blocos: [])
despachar
expect(NotificacaoEnvio.find_by(canal: 'whatsapp').corpo).to eq('Corpo')
end
end
it 'não envia nada quando o canal está desligado na configuração' do
create(:contato, grupo_contato: diretoria, telefone: '11 92005-1157', email: 'dir@reem.com')
create(:grupo_evento_assinatura, grupo_contato: diretoria, evento_notificacao: evento)
config.update_columns(whatsapp_ativo: false, email_notificacoes_ativo: false)
expect { despachar }.not_to change(NotificacaoEnvio, :count)
end
end

View File

@@ -0,0 +1,69 @@
require 'rails_helper'
RSpec.describe Notificacao::Gatilhos do
before { allow(Notificacao::Despachante).to receive(:disparar) }
describe '.valor_alterado' do
let(:consolidacao) { create(:consolidacao, status: :finalizada) }
let(:cm) { create(:consolidacao_motorista, consolidacao: consolidacao, valor_total: 5060.0) }
# Em rascunho o valor muda a cada clique do wizard — avisar ali seria spam.
it 'não dispara enquanto a consolidação é rascunho' do
rascunho = create(:consolidacao, status: :rascunho)
cm_rasc = create(:consolidacao_motorista, consolidacao: rascunho, valor_total: 100.0)
described_class.valor_alterado(rascunho, cm_rasc, anterior: 50.0)
expect(Notificacao::Despachante).not_to have_received(:disparar)
end
it 'não dispara quando o valor não mudou' do
described_class.valor_alterado(consolidacao, cm, anterior: 5060.0)
expect(Notificacao::Despachante).not_to have_received(:disparar)
end
it 'descreve a mudança em {{o_que_mudou}}' do
described_class.valor_alterado(consolidacao, cm, anterior: 4900.0)
expect(Notificacao::Despachante).to have_received(:disparar) do |args|
expect(args[:chave]).to eq('valor_alterado')
expect(args[:dados]['o_que_mudou']).to eq('Valor passou de R$ 4.900,00 para R$ 5.060,00')
expect(args[:dados]['valor']).to eq('R$ 5.060,00')
end
end
it 'não levanta quando algo dá errado — não pode travar um fechamento' do
allow(Notificacao::Despachante).to receive(:disparar).and_raise(StandardError, 'boom')
expect { described_class.valor_alterado(consolidacao, cm, anterior: 1.0) }.not_to raise_error
end
end
describe '.operacao_alterada' do
it 'monta as variáveis do gatilho' do
described_class.operacao_alterada(operacao: 'UBS NORTE AGO 2026',
o_que_mudou: '3 notas na operação a mais', nf: '85382')
expect(Notificacao::Despachante).to have_received(:disparar) do |args|
expect(args[:chave]).to eq('operacao_alterada')
expect(args[:dados]['operacao']).to eq('UBS NORTE AGO 2026')
expect(args[:dados]['nf']).to eq('85382')
end
end
end
# Toda variável que o editor anuncia precisa de par aqui: anunciada e não
# preenchida sai vazia na mensagem, o que é pior do que não existir.
describe 'cobertura das variáveis anunciadas no editor' do
it 'valor_alterado preenche tudo que Variaveis oferece' do
consolidacao = create(:consolidacao, status: :finalizada)
cm = create(:consolidacao_motorista, consolidacao: consolidacao, valor_total: 10.0)
described_class.valor_alterado(consolidacao, cm, anterior: 1.0)
expect(Notificacao::Despachante).to have_received(:disparar) do |args|
esperadas = Notificacao::Variaveis.nomes('valor_alterado') - ['contato'] # vem por destinatário
expect(args[:dados].keys).to include(*esperadas)
end
end
end
end

View File

@@ -0,0 +1,73 @@
require 'rails_helper'
RSpec.describe Notificacao::Renderizador do
let(:dados) { { 'contato' => 'Carlos', 'valor' => 'R$ 5.060,00', 'entregas' => '280' } }
def render(blocos) = described_class.new(blocos, dados)
describe 'as duas saídas do mesmo template' do
let(:blocos) do
[
{ 'tipo' => 'cabecalho', 'titulo' => 'Pagamento fechado', 'subtitulo' => 'UBS Norte' },
{ 'tipo' => 'texto', 'texto' => 'Olá {{contato}}!' },
{ 'tipo' => 'tabela', 'linhas' => [{ 'rotulo' => 'Entregas', 'valor' => '{{entregas}}' },
{ 'rotulo' => 'Valor', 'valor' => '{{valor}}' }] },
{ 'tipo' => 'botao', 'rotulo' => 'Ver extrato', 'url' => 'https://app.reem.com.br/motorista' }
]
end
it 'monta o texto do WhatsApp com negrito e bullets' do
texto = render(blocos).texto
expect(texto).to include('*Pagamento fechado*')
expect(texto).to include('Olá Carlos!')
expect(texto).to include('• Entregas: 280')
expect(texto).to include('Ver extrato: https://app.reem.com.br/motorista')
end
it 'monta o HTML do e-mail com estilo inline' do
html = render(blocos).html
expect(html).to include('<h1')
expect(html).to include('style=') # cliente de e-mail não lê CSS externo
expect(html).to include('Olá Carlos!')
expect(html).to include('R$ 5.060,00')
expect(html).to include('<a href="https://app.reem.com.br/motorista"')
end
end
it 'variável sem valor vira vazio, não fica "{{cru}}" na mensagem' do
texto = render([{ 'tipo' => 'texto', 'texto' => 'Oi {{inexistente}}!' }]).texto
expect(texto).to eq('Oi !')
end
# O corpo é digitado numa tela e o preview usa este mesmo renderizador — um
# escape faltando atingiria primeiro o próprio admin.
it 'escapa HTML do texto digitado e do valor da variável' do
r = described_class.new(
[{ 'tipo' => 'texto', 'texto' => '<script>alert(1)</script> {{contato}}' }],
{ 'contato' => '<img src=x onerror=alert(2)>' }
)
html = r.html
expect(html).not_to include('<script>')
expect(html).not_to include('<img src=x')
expect(html).to include('&lt;script&gt;')
end
# href montado na tela com `javascript:` seria clique armado dentro do e-mail.
it 'ignora botão cuja URL não é http(s)' do
html = render([{ 'tipo' => 'botao', 'rotulo' => 'Clique', 'url' => 'javascript:alert(1)' }]).html
expect(html).not_to include('javascript:')
expect(html).not_to include('<a ')
end
it 'descarta bloco de tipo desconhecido' do
r = render([{ 'tipo' => 'malicioso', 'texto' => 'nada' },
{ 'tipo' => 'texto', 'texto' => 'ok' }])
expect(r.texto).to eq('ok')
end
it 'não deixa bloco vazio virar linha em branco na mensagem' do
texto = render([{ 'tipo' => 'texto', 'texto' => '' },
{ 'tipo' => 'texto', 'texto' => 'único' }]).texto
expect(texto).to eq('único')
end
end

View File

@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe Notificacao::Whatsapp do
let(:config) { ConfiguracaoNotificacao.new(whatsapp_ativo: true) }
it 'recusa número que não dá para normalizar, sem chamar provedor nenhum' do
expect(described_class).not_to receive(:via_baileys)
resposta = described_class.enviar(para: '123', texto: 'oi', config: config)
expect(resposta).not_to be_ok
expect(resposta.erro).to include('inválido')
end
it 'usa a ponte Baileys quando o provedor é baileys' do
config.whatsapp_provedor = 'baileys'
ponte = instance_double(Notificacao::ClienteWhatsapp,
enviar: Notificacao::ClienteWhatsapp::Resposta.new(ok: true))
allow(Notificacao::ClienteWhatsapp).to receive(:padrao).and_return(ponte)
expect(described_class.enviar(para: '(11) 92005-1157', texto: 'oi', config: config)).to be_ok
expect(ponte).to have_received(:enviar).with(para: '+5511920051157', texto: 'oi')
end
it 'devolve erro em vez de levantar quando o Twilio falha' do
config.whatsapp_provedor = 'twilio'
allow(config).to receive(:credenciais_whatsapp)
.and_return({ sid: 'AC1', token: 'x', from: 'whatsapp:+5511000000000' })
allow(Notificacao::ClienteTwilio).to receive(:montar).and_raise(StandardError, 'rede fora')
resposta = described_class.enviar(para: '(11) 92005-1157', texto: 'oi', config: config)
expect(resposta).not_to be_ok
expect(resposta.erro).to include('rede fora')
end
end