iachat/spec/enterprise/services/captain/tools/generate_pix_tool_spec.rb

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