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

@@ -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