Implantação da geração de mensagem de forma livre e mudança na engine de mensagem
This commit is contained in:
@@ -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 }
|
||||
|
||||
59
spec/jobs/detectar_mudancas_operacao_job_spec.rb
Normal file
59
spec/jobs/detectar_mudancas_operacao_job_spec.rb
Normal 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
|
||||
72
spec/jobs/disparar_eventos_agendados_job_spec.rb
Normal file
72
spec/jobs/disparar_eventos_agendados_job_spec.rb
Normal 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
|
||||
43
spec/models/contato_spec.rb
Normal file
43
spec/models/contato_spec.rb
Normal 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
|
||||
69
spec/models/evento_notificacao_spec.rb
Normal file
69
spec/models/evento_notificacao_spec.rb
Normal 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
|
||||
67
spec/models/mensagem_template_spec.rb
Normal file
67
spec/models/mensagem_template_spec.rb
Normal 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
|
||||
134
spec/services/notificacao/despachante_spec.rb
Normal file
134
spec/services/notificacao/despachante_spec.rb
Normal 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
|
||||
69
spec/services/notificacao/gatilhos_spec.rb
Normal file
69
spec/services/notificacao/gatilhos_spec.rb
Normal 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
|
||||
73
spec/services/notificacao/renderizador_spec.rb
Normal file
73
spec/services/notificacao/renderizador_spec.rb
Normal 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('<script>')
|
||||
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
|
||||
35
spec/services/notificacao/whatsapp_spec.rb
Normal file
35
spec/services/notificacao/whatsapp_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user