270 lines
10 KiB
Ruby
270 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe Captain::Tools::GeneratePixTool, type: :model do
|
|
let(:account) { create(:account) }
|
|
let(:assistant) { create(:captain_assistant, account: account) }
|
|
let(:conversation) { create(:conversation, account: account) }
|
|
let(:tool) { described_class.new(assistant) }
|
|
|
|
before do
|
|
conversation.contact.update!(
|
|
name: 'Cliente Teste',
|
|
custom_attributes: conversation.contact.custom_attributes.merge('cpf' => '12345678901')
|
|
)
|
|
end
|
|
|
|
it 'accepts positional params hash without raising NoMethodError' do
|
|
reservation = create_draft_reservation
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
|
|
expect { tool.execute({ amount: '140,00' }) }.not_to raise_error
|
|
expect(tool).to have_received(:generate_new_pix).with(have_attributes(id: reservation.id), amount: 140.0)
|
|
end
|
|
|
|
it 'resolves conversation from tool context state' do
|
|
create_draft_reservation
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
tool_context = Struct.new(:state).new(
|
|
{
|
|
account_id: account.id,
|
|
conversation: { id: conversation.id }
|
|
}
|
|
)
|
|
|
|
result = tool.execute(tool_context, amount: '140,00')
|
|
expect(result).to include(success: true)
|
|
end
|
|
|
|
it 'hydrates cpf and name from recent incoming messages when contact is incomplete' do
|
|
create_draft_reservation
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
|
|
conversation.contact.update!(name: '!@#123', custom_attributes: {})
|
|
create(
|
|
:message,
|
|
account: account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: conversation.contact,
|
|
message_type: :incoming,
|
|
content_type: :text,
|
|
content: "Nome : rodrigo borba machado\nCPF : 002.519.381-31\n\nUma pernoite"
|
|
)
|
|
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
result = tool.execute({ amount: '140,00' })
|
|
|
|
expect(result).to include(success: true)
|
|
expect(tool).to have_received(:generate_new_pix).with(instance_of(Captain::Reservation), amount: 140.0)
|
|
|
|
conversation.contact.reload
|
|
expect(conversation.contact.custom_attributes['cpf']).to eq('00251938131')
|
|
expect(conversation.contact.name).to eq('Rodrigo Borba Machado')
|
|
end
|
|
|
|
it 'asks for cpf conversationally when cpf is missing' do
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
conversation.contact.update!(name: 'Cliente Teste', custom_attributes: {})
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
|
|
result = tool.execute({ amount: '140,00' })
|
|
|
|
expect(result[:success]).to be(true)
|
|
expect(result[:requires_input]).to be(true)
|
|
expect(result[:missing_field]).to eq('cpf')
|
|
expect(result[:formatted_message]).to match(/preciso do seu cpf com 11 dígitos/i)
|
|
expect(tool).not_to have_received(:generate_new_pix)
|
|
end
|
|
|
|
it 'recognizes cpf when customer sends only 11 digits' do
|
|
create_draft_reservation
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
|
|
conversation.contact.update!(name: 'Cliente Teste', custom_attributes: {})
|
|
create(
|
|
:message,
|
|
account: account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: conversation.contact,
|
|
message_type: :incoming,
|
|
content_type: :text,
|
|
content: '00251938131'
|
|
)
|
|
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
result = tool.execute({ amount: '140,00' })
|
|
|
|
expect(result).to include(success: true)
|
|
expect(tool).to have_received(:generate_new_pix).with(instance_of(Captain::Reservation), amount: 140.0)
|
|
expect(conversation.contact.reload.custom_attributes['cpf']).to eq('00251938131')
|
|
end
|
|
|
|
it 'returns a graceful error when conversation cannot be resolved' do
|
|
result = tool.execute(amount: '140,00')
|
|
|
|
expect(result).to include(success: false)
|
|
expect(result[:formatted_message]).to match(/não foi possível identificar a conversa/i)
|
|
end
|
|
|
|
it 'creates a draft reservation automatically from conversation history when needed' do
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
|
|
create(
|
|
:message,
|
|
account: account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: conversation.contact,
|
|
message_type: :incoming,
|
|
content_type: :text,
|
|
content: 'Quero reservar a suíte stilo para 25/02/2026 por uma pernoite'
|
|
)
|
|
create(
|
|
:message,
|
|
account: account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: create(:user, account: account),
|
|
message_type: :outgoing,
|
|
content_type: :text,
|
|
content: 'Perfeito! O valor total é R$ 130,00 e o sinal de 50% é R$ 65,00. Posso gerar o Pix?'
|
|
)
|
|
|
|
result = tool.execute
|
|
|
|
expect(result).to include(success: true)
|
|
expect(tool).to have_received(:generate_new_pix).with(instance_of(Captain::Reservation), amount: 65.0)
|
|
|
|
created = Captain::Reservation.where(conversation_id: conversation.id, status: 'draft').order(created_at: :desc).first
|
|
expect(created).to be_present
|
|
expect(created.suite_identifier.downcase).to include('stilo')
|
|
expect(created.total_amount.to_f).to eq(130.0)
|
|
expect(created.metadata['deposit_amount'].to_f).to eq(65.0)
|
|
end
|
|
|
|
it 'handles ASCII-8BIT message content without crashing during reservation parsing' do
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
|
|
incoming_content = 'Quero reservar a suíte Alexa para amanhã em uma pernoite'.dup.force_encoding('ASCII-8BIT')
|
|
outgoing_content = 'Perfeito! O valor total é R$ 220,00 e o sinal é R$ 110,00.'.dup.force_encoding('ASCII-8BIT')
|
|
|
|
create(
|
|
:message,
|
|
account: account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: conversation.contact,
|
|
message_type: :incoming,
|
|
content_type: :text,
|
|
content: incoming_content
|
|
)
|
|
create(
|
|
:message,
|
|
account: account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: create(:user, account: account),
|
|
message_type: :outgoing,
|
|
content_type: :text,
|
|
content: outgoing_content
|
|
)
|
|
|
|
result = tool.execute
|
|
|
|
expect(result).to include(success: true)
|
|
expect(tool).to have_received(:generate_new_pix).with(instance_of(Captain::Reservation), amount: 110.0)
|
|
end
|
|
|
|
it 'asks conversationally for missing reservation data when draft cannot be inferred' do
|
|
allow(tool).to receive(:generate_new_pix).and_return({ success: true })
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
|
|
result = tool.execute(amount: '140,00')
|
|
|
|
expect(result[:success]).to be(true)
|
|
expect(result[:requires_input]).to be(true)
|
|
expect(result[:missing_fields]).to include('suite', 'check_in')
|
|
expect(result[:formatted_message]).to match(/suíte/i)
|
|
expect(result[:formatted_message]).to match(/check-in/i)
|
|
expect(tool).not_to have_received(:generate_new_pix)
|
|
end
|
|
|
|
it 'returns cpf follow-up when provider rejects cpf payload' do
|
|
reservation = create_draft_reservation
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
|
|
cob_service = instance_double(Captain::Inter::CobService)
|
|
allow(Captain::Inter::CobService).to receive(:new).with(instance_of(Captain::Reservation), amount: 140.0).and_return(cob_service)
|
|
allow(cob_service).to receive(:call).and_raise(StandardError, 'CPF do pagador inválido')
|
|
|
|
result = tool.execute(amount: '140,00')
|
|
|
|
expect(result[:success]).to be(true)
|
|
expect(result[:requires_input]).to be(true)
|
|
expect(result[:missing_field]).to eq('cpf')
|
|
expect(result[:formatted_message]).to match(/cpf com 11 dígitos/i)
|
|
expect(reservation.reload.status).to eq('draft')
|
|
end
|
|
|
|
it 'handles binary provider error messages without raising encoding exceptions' do
|
|
binary_error = StandardError.new('Login/senha invalido'.dup.force_encoding('ASCII-8BIT'))
|
|
|
|
expect { tool.send(:map_pix_error_message, binary_error) }.not_to raise_error
|
|
mapped = tool.send(:map_pix_error_message, binary_error)
|
|
expect(mapped).to match(%r{login/senha inválidos}i)
|
|
end
|
|
|
|
it 'returns conversational message when provider auth fails' do
|
|
create_draft_reservation
|
|
tool.instance_variable_set(:@conversation, conversation)
|
|
|
|
cob_service = instance_double(Captain::Inter::CobService)
|
|
allow(Captain::Inter::CobService).to receive(:new).with(instance_of(Captain::Reservation), amount: 140.0).and_return(cob_service)
|
|
allow(cob_service).to receive(:call).and_raise(StandardError, 'Login/senha invalido'.dup.force_encoding('ASCII-8BIT'))
|
|
|
|
result = tool.execute(amount: '140,00')
|
|
|
|
expect(result[:success]).to be(true)
|
|
expect(result[:formatted_message]).to match(%r{login/senha inválidos}i)
|
|
end
|
|
|
|
it 'normalizes ASCII-8BIT pix payload in tool response to UTF-8' do
|
|
reservation = create_draft_reservation
|
|
binary_pix_code = "/spi/\xE7abc123".dup.force_encoding('ASCII-8BIT')
|
|
sgid = instance_double(SignedGlobalID, to_s: 'signed-token')
|
|
charge = instance_double(Captain::PixCharge, pix_copia_e_cola: binary_pix_code)
|
|
|
|
allow(charge).to receive(:to_sgid).with(expires_in: 2.hours, purpose: :pix_payment).and_return(sgid)
|
|
allow(Rails.application.routes.url_helpers).to receive(:short_payment_link_url).and_return('http://localhost:3000/p/signed-token')
|
|
|
|
result = tool.send(:build_pix_response, charge, reservation, amount: 110.0, prefix: 'Pix gerado')
|
|
|
|
expect(result[:success]).to be(true)
|
|
expect(result[:raw_payload].encoding).to eq(Encoding::UTF_8)
|
|
expect(result[:formatted_message].encoding).to eq(Encoding::UTF_8)
|
|
expect(result[:raw_payload]).to start_with('00020101021226930014BR.GOV.BCB.PIX2571spi-qrcode.bancointer.com.br/spi/')
|
|
end
|
|
|
|
def create_draft_reservation
|
|
Captain::Reservation.create!(
|
|
account: account,
|
|
inbox: conversation.inbox,
|
|
contact: conversation.contact,
|
|
contact_inbox: conversation.contact_inbox,
|
|
conversation: conversation,
|
|
suite_identifier: 'Suite 101',
|
|
check_in_at: 1.day.from_now.change(hour: 19, min: 0, sec: 0),
|
|
check_out_at: 2.days.from_now.change(hour: 12, min: 0, sec: 0),
|
|
status: :draft,
|
|
total_amount: 280.0,
|
|
metadata: { deposit_amount: 140.0 }
|
|
)
|
|
end
|
|
end
|